From bf14a5d327431372c571fe677153e653ec984d94 Mon Sep 17 00:00:00 2001 From: laosb Date: Thu, 23 Apr 2026 09:50:12 +0800 Subject: [PATCH] feat: profile management --- .github/actions/setup-swift/action.yml | 6 +- Sources/AgentIsolation/ProfileManager.swift | 205 ++++++++++++++++ Sources/agentc/Commands/AgentcCommand.swift | 1 + Sources/agentc/Commands/ProfilesCommand.swift | 176 ++++++++++++++ .../ProfileManagerTests.swift | 225 ++++++++++++++++++ .../ProfilesCommandIntegrationTests.swift | 185 ++++++++++++++ 6 files changed, 795 insertions(+), 3 deletions(-) create mode 100644 Sources/AgentIsolation/ProfileManager.swift create mode 100644 Sources/agentc/Commands/ProfilesCommand.swift create mode 100644 Tests/AgentIsolationTests/ProfileManagerTests.swift create mode 100644 Tests/AgentcIntegrationTests/ProfilesCommandIntegrationTests.swift diff --git a/.github/actions/setup-swift/action.yml b/.github/actions/setup-swift/action.yml index 01b0465..21053cd 100644 --- a/.github/actions/setup-swift/action.yml +++ b/.github/actions/setup-swift/action.yml @@ -13,7 +13,7 @@ runs: - name: Setup Swift uses: SwiftyLab/setup-swift@latest with: - swift-version: "6.3.1" + swift-version: "6.3" - name: Cache Static Linux SDK if: inputs.static-sdk == 'true' @@ -21,9 +21,9 @@ runs: uses: actions/cache@v4 with: path: ~/.swiftpm/swift-sdks - key: static-sdk-6.3.1-${{ runner.arch }} + key: static-sdk-6.3-${{ runner.arch }} - name: Install Static Linux SDK if: inputs.static-sdk == 'true' && steps.cache-static-sdk.outputs.cache-hit != 'true' shell: bash - run: swift sdk install https://download.swift.org/swift-6.3.1-release/static-sdk/swift-6.3.1-RELEASE/swift-6.3.1-RELEASE_static-linux-0.1.0.artifactbundle.tar.gz --checksum fac05271c1f7d060bd203240ce5251d5ca902d30ac899f553765dbb3a88b97ad + run: swift sdk install https://download.swift.org/swift-6.3-release/static-sdk/swift-6.3-RELEASE/swift-6.3-RELEASE_static-linux-0.1.0.artifactbundle.tar.gz --checksum d2078b69bdeb5c31202c10e9d8a11d6f66f82938b51a4b75f032ccb35c4c286c diff --git a/Sources/AgentIsolation/ProfileManager.swift b/Sources/AgentIsolation/ProfileManager.swift new file mode 100644 index 0000000..e316845 --- /dev/null +++ b/Sources/AgentIsolation/ProfileManager.swift @@ -0,0 +1,205 @@ +import Foundation + +/// Basic information about a profile, as surfaced by ``ProfileManager/list()``. +public struct ProfileInfo: Sendable, Equatable { + /// The profile name (the directory's last path component). + public let name: String + /// Absolute path to the profile's directory, e.g. `~/.agentc/profiles/`. + public let path: URL + + public init(name: String, path: URL) { + self.name = name + self.path = path + } +} + +/// Detailed information about a profile, as surfaced by ``ProfileManager/inspect(name:)``. +public struct ProfileDetails: Sendable, Equatable { + /// The profile name. + public let name: String + /// Absolute path to the profile's directory. + public let path: URL + /// Absolute path to the profile's `home` subdirectory (mounted as `/home/agent`). + public let homeDirectory: URL + /// Whether the `home` subdirectory exists on disk. + public let homeDirectoryExists: Bool + /// Total on-disk byte size of the profile directory (sum of regular file sizes). + public let sizeBytes: Int64 + /// The most recent modification date across any file in the profile directory. + public let lastModified: Date? + + public init( + name: String, + path: URL, + homeDirectory: URL, + homeDirectoryExists: Bool, + sizeBytes: Int64, + lastModified: Date? + ) { + self.name = name + self.path = path + self.homeDirectory = homeDirectory + self.homeDirectoryExists = homeDirectoryExists + self.sizeBytes = sizeBytes + self.lastModified = lastModified + } +} + +/// Errors thrown by ``ProfileManager``. +public enum ProfileManagerError: Error, Equatable, Sendable { + /// The profile storage directory exists but is not a directory. + case storageNotADirectory(URL) + /// A profile with the given name does not exist. + case profileNotFound(name: String) + /// The supplied name contains path separators or other invalid characters. + case invalidProfileName(String) +} + +/// Manages the on-disk profile storage directory for agentc (e.g. `~/.agentc/profiles`). +/// +/// A `ProfileManager` can enumerate, inspect, and delete profiles. Each profile is a +/// subdirectory under the storage directory; a profile's `home` subdirectory is what +/// gets mounted into the container as `/home/agent`. +public struct ProfileManager: Sendable { + /// The profile storage directory (e.g. `~/.agentc/profiles`). + public let storageDirectory: URL + + public init(storageDirectory: URL) { + self.storageDirectory = storageDirectory + } + + /// List every profile present in the storage directory. + /// + /// Returns an empty array when the storage directory does not yet exist. + /// Results are sorted alphabetically by name and exclude any entry whose name + /// begins with a dot. + public func list() throws -> [ProfileInfo] { + let fm = FileManager.default + var isDir: ObjCBool = false + guard fm.fileExists(atPath: storageDirectory.path, isDirectory: &isDir) else { + return [] + } + guard isDir.boolValue else { + throw ProfileManagerError.storageNotADirectory(storageDirectory) + } + + let entries = try fm.contentsOfDirectory( + at: storageDirectory, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) + + var infos: [ProfileInfo] = [] + for entry in entries { + let values = try? entry.resourceValues(forKeys: [.isDirectoryKey]) + guard values?.isDirectory == true else { continue } + infos.append(ProfileInfo(name: entry.lastPathComponent, path: entry)) + } + infos.sort { $0.name < $1.name } + return infos + } + + /// Whether a profile with the given name exists on disk. + public func exists(name: String) -> Bool { + guard let dir = try? profileDirectory(for: name) else { return false } + var isDir: ObjCBool = false + return FileManager.default.fileExists(atPath: dir.path, isDirectory: &isDir) && isDir.boolValue + } + + /// Inspect a profile, returning details such as its home directory path and total size. + public func inspect(name: String) throws -> ProfileDetails { + let dir = try profileDirectory(for: name) + var isDir: ObjCBool = false + guard FileManager.default.fileExists(atPath: dir.path, isDirectory: &isDir), + isDir.boolValue + else { + throw ProfileManagerError.profileNotFound(name: name) + } + + let home = dir.appendingPathComponent("home") + var homeIsDir: ObjCBool = false + let homeExists = FileManager.default.fileExists(atPath: home.path, isDirectory: &homeIsDir) + let (size, modified) = try Self.directoryStats(at: dir) + + return ProfileDetails( + name: name, + path: dir, + homeDirectory: home, + homeDirectoryExists: homeExists && homeIsDir.boolValue, + sizeBytes: size, + lastModified: modified + ) + } + + /// Delete a profile. Throws ``ProfileManagerError/profileNotFound(name:)`` when + /// no profile with the given name exists. + public func delete(name: String) throws { + let dir = try profileDirectory(for: name) + var isDir: ObjCBool = false + guard FileManager.default.fileExists(atPath: dir.path, isDirectory: &isDir), + isDir.boolValue + else { + throw ProfileManagerError.profileNotFound(name: name) + } + try FileManager.default.removeItem(at: dir) + } + + // MARK: - Helpers + + /// Resolve and validate a profile name to its on-disk directory. + private func profileDirectory(for name: String) throws -> URL { + try Self.validate(name: name) + return storageDirectory.appendingPathComponent(name, isDirectory: true) + } + + /// Reject names that contain path separators, are empty, or refer to the current or + /// parent directory. + private static func validate(name: String) throws { + if name.isEmpty || name == "." || name == ".." + || name.contains("/") || name.contains("\\") + || name.contains("\0") + { + throw ProfileManagerError.invalidProfileName(name) + } + } + + /// Recursively walk a directory, summing regular-file sizes and tracking the most + /// recent modification date. + private static func directoryStats(at url: URL) throws -> (Int64, Date?) { + let fm = FileManager.default + let keys: [URLResourceKey] = [ + .isRegularFileKey, + .fileSizeKey, + .totalFileAllocatedSizeKey, + .fileAllocatedSizeKey, + .contentModificationDateKey, + ] + guard + let enumerator = fm.enumerator( + at: url, + includingPropertiesForKeys: keys, + options: [] + ) + else { + return (0, nil) + } + + var total: Int64 = 0 + var mostRecent: Date? = nil + for case let fileURL as URL in enumerator { + let values = try fileURL.resourceValues(forKeys: Set(keys)) + if values.isRegularFile == true { + let size = values.fileSize ?? values.totalFileAllocatedSize ?? values.fileAllocatedSize ?? 0 + total += Int64(size) + } + if let modified = values.contentModificationDate { + if let current = mostRecent { + if modified > current { mostRecent = modified } + } else { + mostRecent = modified + } + } + } + return (total, mostRecent) + } +} diff --git a/Sources/agentc/Commands/AgentcCommand.swift b/Sources/agentc/Commands/AgentcCommand.swift index cf82ed2..72488b7 100644 --- a/Sources/agentc/Commands/AgentcCommand.swift +++ b/Sources/agentc/Commands/AgentcCommand.swift @@ -17,6 +17,7 @@ struct AgentcCommand: AsyncParsableCommand { RunCommand.self, ShellCommand.self, InitCommand.self, + ProfilesCommand.self, VersionCommand.self, MigrateFromClaudecCommand.self, ], diff --git a/Sources/agentc/Commands/ProfilesCommand.swift b/Sources/agentc/Commands/ProfilesCommand.swift new file mode 100644 index 0000000..a1044e5 --- /dev/null +++ b/Sources/agentc/Commands/ProfilesCommand.swift @@ -0,0 +1,176 @@ +import AgentIsolation +import ArgumentParser + +#if canImport(FoundationEssentials) + import FoundationEssentials +#else + import Foundation +#endif + +struct ProfilesCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "profiles", + abstract: "List, inspect, and remove agentc profiles", + discussion: """ + Profiles are stored under `~/.agentc/profiles//`. A profile's `home` \ + subdirectory is mounted into the container as `/home/agent`. + + Examples: + agentc profiles # list all profiles + agentc profiles list # same as above + agentc profiles remove work # delete the "work" profile + agentc profiles rm work # same, shorter alias + """, + subcommands: [ + ProfilesListCommand.self, + ProfilesRemoveCommand.self, + ], + defaultSubcommand: ProfilesListCommand.self + ) +} + +// MARK: - Shared storage resolution + +extension ProfilesCommand { + /// Resolve the profiles storage directory, honoring `--profiles-dir` when given. + static func resolveStorageDirectory(explicit: String?) -> URL { + if let explicit, !explicit.isEmpty { + return URL(fileURLWithPath: explicit) + } + return MigrationCheck.homeDir.appendingPathComponent(".agentc/profiles") + } + + /// Format a byte count into a short human-readable string (e.g. "2.3 MiB"). + static func formatSize(_ bytes: Int64) -> String { + let units = ["B", "KiB", "MiB", "GiB", "TiB"] + var value = Double(bytes) + var unit = 0 + while value >= 1024, unit < units.count - 1 { + value /= 1024 + unit += 1 + } + if unit == 0 { + return "\(Int64(value)) \(units[unit])" + } + return String(format: "%.1f %@", value, units[unit]) + } + + static func formatDate(_ date: Date) -> String { + date.formatted(.iso8601) + } +} + +// MARK: - list + +struct ProfilesListCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List profiles in the profiles storage folder" + ) + + @Option( + name: .customLong("profiles-dir"), + help: "Profiles storage directory (default: ~/.agentc/profiles)." + ) + var profilesDir: String? + + @Flag(name: .long, help: "Print detailed information for each profile.") + var verbose: Bool = false + + @Argument(help: "Optional profile name to inspect. When set, prints just that profile's details.") + var name: String? + + mutating func run() async throws { + let storage = ProfilesCommand.resolveStorageDirectory(explicit: profilesDir) + let manager = ProfileManager(storageDirectory: storage) + + if let name { + let details = try manager.inspect(name: name) + printDetails(details) + return + } + + let profiles = try manager.list() + if profiles.isEmpty { + print("No profiles found in \(storage.path).") + return + } + + if verbose { + for (i, info) in profiles.enumerated() { + if i > 0 { print() } + let details = try manager.inspect(name: info.name) + printDetails(details) + } + } else { + for info in profiles { + print(info.name) + } + } + } + + private func printDetails(_ details: ProfileDetails) { + print("name: \(details.name)") + print("path: \(details.path.path)") + print("home: \(details.homeDirectory.path)\(details.homeDirectoryExists ? "" : " (missing)")") + print("size: \(ProfilesCommand.formatSize(details.sizeBytes))") + if let modified = details.lastModified { + print("lastModified: \(ProfilesCommand.formatDate(modified))") + } else { + print("lastModified: (unknown)") + } + } +} + +// MARK: - remove / rm + +struct ProfilesRemoveCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "remove", + abstract: "Delete a profile and all of its data", + aliases: ["rm"] + ) + + @Option( + name: .customLong("profiles-dir"), + help: "Profiles storage directory (default: ~/.agentc/profiles)." + ) + var profilesDir: String? + + @Flag(name: .shortAndLong, help: "Do not error out when the profile does not exist.") + var force: Bool = false + + @Argument(help: "Name(s) of the profile(s) to delete.") + var names: [String] + + mutating func run() async throws { + guard !names.isEmpty else { + writeToStderr("agentc: profiles remove: at least one profile name is required.\n") + throw ExitCode(2) + } + + let storage = ProfilesCommand.resolveStorageDirectory(explicit: profilesDir) + let manager = ProfileManager(storageDirectory: storage) + + var failed = false + for name in names { + do { + try manager.delete(name: name) + print("agentc: removed profile \"\(name)\"") + } catch ProfileManagerError.profileNotFound { + if force { + continue + } + writeToStderr("agentc: profile \"\(name)\" does not exist in \(storage.path).\n") + failed = true + } catch ProfileManagerError.invalidProfileName(let raw) { + writeToStderr("agentc: invalid profile name \"\(raw)\".\n") + failed = true + } + } + + if failed { + throw ExitCode(1) + } + } +} diff --git a/Tests/AgentIsolationTests/ProfileManagerTests.swift b/Tests/AgentIsolationTests/ProfileManagerTests.swift new file mode 100644 index 0000000..d06b9e8 --- /dev/null +++ b/Tests/AgentIsolationTests/ProfileManagerTests.swift @@ -0,0 +1,225 @@ +import AgentIsolation +import Foundation +import Testing + +@Suite("ProfileManager") +struct ProfileManagerTests { + + // MARK: - Helpers + + private func makeTempStorage() -> URL { + let dir = URL(fileURLWithPath: "/tmp/agentc-pm-\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + private func createProfile( + _ name: String, + in storage: URL, + files: [(String, String)] = [("home/.bashrc", "# hello\n")] + ) throws { + let profileDir = storage.appendingPathComponent(name) + try FileManager.default.createDirectory(at: profileDir, withIntermediateDirectories: true) + for (relPath, contents) in files { + let fileURL = profileDir.appendingPathComponent(relPath) + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try contents.write(to: fileURL, atomically: true, encoding: .utf8) + } + } + + // MARK: - list + + @Test("list returns empty when storage dir does not exist") + func listMissingStorage() throws { + let base = URL(fileURLWithPath: "/tmp/agentc-pm-missing-\(UUID().uuidString)") + let manager = ProfileManager(storageDirectory: base) + let profiles = try manager.list() + #expect(profiles.isEmpty) + } + + @Test("list returns empty when storage dir is empty") + func listEmptyStorage() throws { + let storage = makeTempStorage() + defer { try? FileManager.default.removeItem(at: storage) } + + let manager = ProfileManager(storageDirectory: storage) + #expect(try manager.list().isEmpty) + } + + @Test("list returns all profiles, sorted alphabetically") + func listProfilesSorted() throws { + let storage = makeTempStorage() + defer { try? FileManager.default.removeItem(at: storage) } + + for name in ["charlie", "alice", "bob"] { + try createProfile(name, in: storage) + } + + let manager = ProfileManager(storageDirectory: storage) + let profiles = try manager.list() + #expect(profiles.map(\.name) == ["alice", "bob", "charlie"]) + #expect(profiles[0].path.lastPathComponent == "alice") + } + + @Test("list ignores regular files and hidden dotfiles at top level") + func listIgnoresFilesAndHidden() throws { + let storage = makeTempStorage() + defer { try? FileManager.default.removeItem(at: storage) } + + try createProfile("alice", in: storage) + // Random file at the profiles storage root + try "junk".write( + to: storage.appendingPathComponent("not-a-profile.txt"), atomically: true, encoding: .utf8) + // Hidden directory + try FileManager.default.createDirectory( + at: storage.appendingPathComponent(".cache"), withIntermediateDirectories: true) + + let manager = ProfileManager(storageDirectory: storage) + let profiles = try manager.list() + #expect(profiles.map(\.name) == ["alice"]) + } + + @Test("list throws when storage path points to a regular file") + func listNonDirectoryStorage() throws { + let base = URL(fileURLWithPath: "/tmp/agentc-pm-file-\(UUID().uuidString)") + try "not a dir".write(to: base, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: base) } + + let manager = ProfileManager(storageDirectory: base) + #expect(throws: ProfileManagerError.self) { + _ = try manager.list() + } + } + + // MARK: - exists + + @Test("exists returns true for existing profile, false otherwise") + func existsBehaviour() throws { + let storage = makeTempStorage() + defer { try? FileManager.default.removeItem(at: storage) } + + try createProfile("alice", in: storage) + let manager = ProfileManager(storageDirectory: storage) + #expect(manager.exists(name: "alice")) + #expect(!manager.exists(name: "bob")) + // Invalid names return false (do not throw) + #expect(!manager.exists(name: "")) + #expect(!manager.exists(name: "../etc")) + } + + // MARK: - inspect + + @Test("inspect returns details including home path and size") + func inspectReturnsDetails() throws { + let storage = makeTempStorage() + defer { try? FileManager.default.removeItem(at: storage) } + + try createProfile( + "alice", + in: storage, + files: [ + ("home/.bashrc", "abc"), + ("home/projects/a.txt", "12345"), + ] + ) + + let manager = ProfileManager(storageDirectory: storage) + let details = try manager.inspect(name: "alice") + + #expect(details.name == "alice") + #expect(details.path == storage.appendingPathComponent("alice")) + #expect(details.homeDirectory == storage.appendingPathComponent("alice/home")) + #expect(details.homeDirectoryExists) + // 3 + 5 bytes of file content (may be higher on some filesystems where + // directory entries are counted, but sum of regular files should match) + #expect(details.sizeBytes == 8) + #expect(details.lastModified != nil) + } + + @Test("inspect marks missing home directory") + func inspectMissingHome() throws { + let storage = makeTempStorage() + defer { try? FileManager.default.removeItem(at: storage) } + + // Profile directory without home/ + let profileDir = storage.appendingPathComponent("bare") + try FileManager.default.createDirectory(at: profileDir, withIntermediateDirectories: true) + + let manager = ProfileManager(storageDirectory: storage) + let details = try manager.inspect(name: "bare") + #expect(!details.homeDirectoryExists) + #expect(details.sizeBytes == 0) + } + + @Test("inspect throws profileNotFound for missing profile") + func inspectMissing() throws { + let storage = makeTempStorage() + defer { try? FileManager.default.removeItem(at: storage) } + + let manager = ProfileManager(storageDirectory: storage) + #expect(throws: ProfileManagerError.profileNotFound(name: "ghost")) { + _ = try manager.inspect(name: "ghost") + } + } + + @Test("inspect rejects names with path separators") + func inspectRejectsInvalidNames() throws { + let storage = makeTempStorage() + defer { try? FileManager.default.removeItem(at: storage) } + + let manager = ProfileManager(storageDirectory: storage) + for bad in ["../outside", "foo/bar", "", "."] { + #expect(throws: ProfileManagerError.self) { + _ = try manager.inspect(name: bad) + } + } + } + + // MARK: - delete + + @Test("delete removes the profile directory") + func deleteRemovesDirectory() throws { + let storage = makeTempStorage() + defer { try? FileManager.default.removeItem(at: storage) } + + try createProfile("alice", in: storage) + try createProfile("bob", in: storage) + + let manager = ProfileManager(storageDirectory: storage) + try manager.delete(name: "alice") + + #expect(!manager.exists(name: "alice")) + #expect(manager.exists(name: "bob")) + #expect(try manager.list().map(\.name) == ["bob"]) + } + + @Test("delete throws profileNotFound for missing profile") + func deleteMissing() throws { + let storage = makeTempStorage() + defer { try? FileManager.default.removeItem(at: storage) } + + let manager = ProfileManager(storageDirectory: storage) + #expect(throws: ProfileManagerError.profileNotFound(name: "ghost")) { + try manager.delete(name: "ghost") + } + } + + @Test("delete rejects names with path separators") + func deleteRejectsInvalid() throws { + let storage = makeTempStorage() + defer { try? FileManager.default.removeItem(at: storage) } + + // Create a sibling directory that a naive implementation might wipe. + let outside = storage.deletingLastPathComponent().appendingPathComponent( + "agentc-pm-sibling-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: outside, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: outside) } + + let manager = ProfileManager(storageDirectory: storage) + #expect(throws: ProfileManagerError.self) { + try manager.delete(name: "../\(outside.lastPathComponent)") + } + #expect(FileManager.default.fileExists(atPath: outside.path)) + } +} diff --git a/Tests/AgentcIntegrationTests/ProfilesCommandIntegrationTests.swift b/Tests/AgentcIntegrationTests/ProfilesCommandIntegrationTests.swift new file mode 100644 index 0000000..8987018 --- /dev/null +++ b/Tests/AgentcIntegrationTests/ProfilesCommandIntegrationTests.swift @@ -0,0 +1,185 @@ +import Foundation +import Testing + +@Suite("Profiles Command Integration Tests") +struct ProfilesCommandIntegrationTests { + + // MARK: - Helpers + + private func makeStorage() -> URL { + let base = URL( + fileURLWithPath: "/tmp/__TEST_agentc_profiles.\(UUID().uuidString.prefix(8))") + try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + return base + } + + private func createProfile(_ name: String, in storage: URL, with content: String = "hi\n") throws + { + let home = storage.appendingPathComponent("\(name)/home") + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + try content.write( + to: home.appendingPathComponent(".bashrc"), atomically: true, encoding: .utf8) + } + + // MARK: - list + + @Test("agentc profiles lists profile names") + func listProfileNames() async throws { + let storage = makeStorage() + defer { try? FileManager.default.removeItem(at: storage) } + + try createProfile("alice", in: storage) + try createProfile("bob", in: storage) + + let result = await runAgentc(args: ["profiles", "--profiles-dir", storage.path]) + #expect(result.exitCode == 0) + let names = + result.stdout + .split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + #expect(names == ["alice", "bob"]) + } + + @Test("agentc profiles list with no profiles shows helpful message") + func listEmptyShowsMessage() async throws { + let storage = makeStorage() + defer { try? FileManager.default.removeItem(at: storage) } + + let result = await runAgentc(args: ["profiles", "list", "--profiles-dir", storage.path]) + #expect(result.exitCode == 0) + #expect(result.stdout.contains("No profiles found")) + } + + @Test("agentc profiles --verbose prints home/size/modified") + func listVerbosePrintsDetails() async throws { + let storage = makeStorage() + defer { try? FileManager.default.removeItem(at: storage) } + + try createProfile("alice", in: storage, with: "hello world") // 11 bytes + + let result = await runAgentc( + args: ["profiles", "list", "--profiles-dir", storage.path, "--verbose"]) + #expect(result.exitCode == 0) + #expect(result.stdout.contains("name: alice")) + #expect(result.stdout.contains("home:")) + #expect(result.stdout.contains("size:")) + #expect(result.stdout.contains("lastModified:")) + #expect(result.stdout.contains("/alice/home")) + } + + @Test("agentc profiles inspects a single profile") + func inspectSingleProfile() async throws { + let storage = makeStorage() + defer { try? FileManager.default.removeItem(at: storage) } + + try createProfile("alice", in: storage) + try createProfile("bob", in: storage) + + let result = await runAgentc( + args: ["profiles", "list", "alice", "--profiles-dir", storage.path]) + #expect(result.exitCode == 0) + #expect(result.stdout.contains("name: alice")) + #expect(!result.stdout.contains("name: bob")) + } + + @Test("agentc profiles on missing profile errors out") + func inspectMissingProfileFails() async throws { + let storage = makeStorage() + defer { try? FileManager.default.removeItem(at: storage) } + + let result = await runAgentc( + args: ["profiles", "list", "ghost", "--profiles-dir", storage.path]) + #expect(result.exitCode != 0) + } + + // MARK: - remove / rm + + @Test("agentc profiles remove deletes the profile") + func removeDeletesProfile() async throws { + let storage = makeStorage() + defer { try? FileManager.default.removeItem(at: storage) } + + try createProfile("alice", in: storage) + try createProfile("bob", in: storage) + + let result = await runAgentc( + args: ["profiles", "remove", "--profiles-dir", storage.path, "alice"]) + #expect(result.exitCode == 0) + #expect(result.stdout.contains("removed profile \"alice\"")) + + #expect(!FileManager.default.fileExists(atPath: storage.appendingPathComponent("alice").path)) + #expect(FileManager.default.fileExists(atPath: storage.appendingPathComponent("bob").path)) + } + + @Test("agentc profiles rm (alias) deletes the profile") + func rmAliasDeletes() async throws { + let storage = makeStorage() + defer { try? FileManager.default.removeItem(at: storage) } + + try createProfile("alice", in: storage) + + let result = await runAgentc( + args: ["profiles", "rm", "--profiles-dir", storage.path, "alice"]) + #expect(result.exitCode == 0) + #expect(!FileManager.default.fileExists(atPath: storage.appendingPathComponent("alice").path)) + } + + @Test("agentc profiles rm fails when the profile is missing") + func rmMissingFails() async throws { + let storage = makeStorage() + defer { try? FileManager.default.removeItem(at: storage) } + + let result = await runAgentc( + args: ["profiles", "rm", "--profiles-dir", storage.path, "ghost"]) + #expect(result.exitCode != 0) + #expect(result.stderr.contains("does not exist")) + } + + @Test("agentc profiles rm --force silently ignores missing profiles") + func rmForceSwallowsMissing() async throws { + let storage = makeStorage() + defer { try? FileManager.default.removeItem(at: storage) } + + let result = await runAgentc( + args: ["profiles", "rm", "--profiles-dir", storage.path, "--force", "ghost"]) + #expect(result.exitCode == 0) + } + + @Test("agentc profiles rm accepts multiple names") + func rmMultiple() async throws { + let storage = makeStorage() + defer { try? FileManager.default.removeItem(at: storage) } + + try createProfile("alice", in: storage) + try createProfile("bob", in: storage) + try createProfile("charlie", in: storage) + + let result = await runAgentc( + args: ["profiles", "rm", "--profiles-dir", storage.path, "alice", "charlie"]) + #expect(result.exitCode == 0) + + #expect(!FileManager.default.fileExists(atPath: storage.appendingPathComponent("alice").path)) + #expect(FileManager.default.fileExists(atPath: storage.appendingPathComponent("bob").path)) + #expect(!FileManager.default.fileExists(atPath: storage.appendingPathComponent("charlie").path)) + } + + @Test("agentc profiles rm rejects names with path separators") + func rmRejectsTraversal() async throws { + let storage = makeStorage() + defer { try? FileManager.default.removeItem(at: storage) } + + // Create a sibling directory outside storage that a path-traversal would target + let outside = storage.deletingLastPathComponent().appendingPathComponent( + "__TEST_agentc_outside.\(UUID().uuidString.prefix(6))") + try FileManager.default.createDirectory(at: outside, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: outside) } + + let result = await runAgentc( + args: [ + "profiles", "rm", "--profiles-dir", storage.path, "../\(outside.lastPathComponent)", + ]) + #expect(result.exitCode != 0) + #expect(FileManager.default.fileExists(atPath: outside.path)) + } +}