From 52664167cf0a3cfee95112a755dcd1378d645c6c Mon Sep 17 00:00:00 2001 From: laosb Date: Thu, 16 Apr 2026 17:20:15 +0800 Subject: [PATCH 1/3] refactor: Extract common logic to SessionRunner. --- Sources/AgentIsolation/AgentSession.swift | 6 +- Sources/AgentIsolation/IsolationConfig.swift | 6 +- Sources/AgentIsolation/PathUtils.swift | 7 +- Sources/AgentIsolation/ProjectSettings.swift | 6 +- .../DockerAPIClient.swift | 7 +- .../DockerModels.swift | 6 +- .../DockerRuntime.swift | 7 +- .../DockerStreamAttach.swift | 7 +- Sources/agentc/BootstrapManager.swift | 6 +- .../agentc/{ => Commands}/AgentcCommand.swift | 0 .../MigrateFromClaudecCommand.swift | 7 +- Sources/agentc/Commands/RunCommand.swift | 44 ++++++ Sources/agentc/Commands/ShellCommand.swift | 55 ++++++++ .../{ => Commands}/VersionCommand.swift | 0 Sources/agentc/ConfigurationsManager.swift | 6 +- Sources/agentc/MigrationCheck.swift | 7 +- Sources/agentc/RuntimeResolver.swift | 7 +- .../{RunCommand.swift => SessionRunner.swift} | 98 +++++++------ Sources/agentc/SharedOptions.swift | 7 +- Sources/agentc/ShellCommand.swift | 131 ------------------ 20 files changed, 234 insertions(+), 186 deletions(-) rename Sources/agentc/{ => Commands}/AgentcCommand.swift (100%) rename Sources/agentc/{ => Commands}/MigrateFromClaudecCommand.swift (95%) create mode 100644 Sources/agentc/Commands/RunCommand.swift create mode 100644 Sources/agentc/Commands/ShellCommand.swift rename Sources/agentc/{ => Commands}/VersionCommand.swift (100%) rename Sources/agentc/{RunCommand.swift => SessionRunner.swift} (61%) delete mode 100644 Sources/agentc/ShellCommand.swift diff --git a/Sources/AgentIsolation/AgentSession.swift b/Sources/AgentIsolation/AgentSession.swift index c5771b6..7e7e25d 100644 --- a/Sources/AgentIsolation/AgentSession.swift +++ b/Sources/AgentIsolation/AgentSession.swift @@ -1,4 +1,8 @@ -import Foundation +#if canImport(FoundationEssentials) + import FoundationEssentials +#else + import Foundation +#endif /// Settings from an agent configuration's settings.json. private struct AgentConfigurationSettings: Decodable { diff --git a/Sources/AgentIsolation/IsolationConfig.swift b/Sources/AgentIsolation/IsolationConfig.swift index d170f53..26d11af 100644 --- a/Sources/AgentIsolation/IsolationConfig.swift +++ b/Sources/AgentIsolation/IsolationConfig.swift @@ -1,4 +1,8 @@ -import Foundation +#if canImport(FoundationEssentials) + import FoundationEssentials +#else + import Foundation +#endif /// Determines how the container entrypoint is configured. public enum BootstrapMode: Sendable { diff --git a/Sources/AgentIsolation/PathUtils.swift b/Sources/AgentIsolation/PathUtils.swift index f31d1d5..d4e370d 100644 --- a/Sources/AgentIsolation/PathUtils.swift +++ b/Sources/AgentIsolation/PathUtils.swift @@ -1,5 +1,10 @@ import Crypto -import Foundation + +#if canImport(FoundationEssentials) + import FoundationEssentials +#else + import Foundation +#endif public enum AgentIsolationPathUtils { /// Resolve symlinks with platform consideration. diff --git a/Sources/AgentIsolation/ProjectSettings.swift b/Sources/AgentIsolation/ProjectSettings.swift index 18825ab..2090dcf 100644 --- a/Sources/AgentIsolation/ProjectSettings.swift +++ b/Sources/AgentIsolation/ProjectSettings.swift @@ -1,4 +1,8 @@ -import Foundation +#if canImport(FoundationEssentials) + import FoundationEssentials +#else + import Foundation +#endif /// Project-level settings loaded from `.agentc/settings.json`. /// diff --git a/Sources/AgentIsolationDockerRuntime/DockerAPIClient.swift b/Sources/AgentIsolationDockerRuntime/DockerAPIClient.swift index 017ad2b..454af22 100644 --- a/Sources/AgentIsolationDockerRuntime/DockerAPIClient.swift +++ b/Sources/AgentIsolationDockerRuntime/DockerAPIClient.swift @@ -1,8 +1,13 @@ import AsyncHTTPClient -import Foundation import NIOCore import NIOFoundationCompat +#if canImport(FoundationEssentials) + import FoundationEssentials +#else + import Foundation +#endif + /// HTTP client for Docker Engine API v1.44. /// /// Supports Unix domain socket and TCP connections. diff --git a/Sources/AgentIsolationDockerRuntime/DockerModels.swift b/Sources/AgentIsolationDockerRuntime/DockerModels.swift index 1d32ff8..f52a165 100644 --- a/Sources/AgentIsolationDockerRuntime/DockerModels.swift +++ b/Sources/AgentIsolationDockerRuntime/DockerModels.swift @@ -1,4 +1,8 @@ -import Foundation +#if canImport(FoundationEssentials) + import FoundationEssentials +#else + import Foundation +#endif // MARK: - Image Types diff --git a/Sources/AgentIsolationDockerRuntime/DockerRuntime.swift b/Sources/AgentIsolationDockerRuntime/DockerRuntime.swift index f69a02c..389505d 100644 --- a/Sources/AgentIsolationDockerRuntime/DockerRuntime.swift +++ b/Sources/AgentIsolationDockerRuntime/DockerRuntime.swift @@ -1,8 +1,13 @@ import AgentIsolation -import Foundation import Logging import Synchronization +#if canImport(FoundationEssentials) + import FoundationEssentials +#else + import Foundation +#endif + // MARK: - DockerRuntime /// Container runtime that communicates with Docker Engine via its HTTP API (v1.44). diff --git a/Sources/AgentIsolationDockerRuntime/DockerStreamAttach.swift b/Sources/AgentIsolationDockerRuntime/DockerStreamAttach.swift index e1adeff..f624e15 100644 --- a/Sources/AgentIsolationDockerRuntime/DockerStreamAttach.swift +++ b/Sources/AgentIsolationDockerRuntime/DockerStreamAttach.swift @@ -1,7 +1,12 @@ import AgentIsolation -import Foundation import Synchronization +#if canImport(FoundationEssentials) + import FoundationEssentials +#else + import Foundation +#endif + #if canImport(Darwin) import Darwin #elseif canImport(Glibc) diff --git a/Sources/agentc/BootstrapManager.swift b/Sources/agentc/BootstrapManager.swift index 0e8753f..82633d4 100644 --- a/Sources/agentc/BootstrapManager.swift +++ b/Sources/agentc/BootstrapManager.swift @@ -1,4 +1,8 @@ -import Foundation +#if canImport(FoundationEssentials) + import FoundationEssentials +#else + import Foundation +#endif /// Locates or downloads the agentc-bootstrap binary used as the container entrypoint. enum BootstrapManager { diff --git a/Sources/agentc/AgentcCommand.swift b/Sources/agentc/Commands/AgentcCommand.swift similarity index 100% rename from Sources/agentc/AgentcCommand.swift rename to Sources/agentc/Commands/AgentcCommand.swift diff --git a/Sources/agentc/MigrateFromClaudecCommand.swift b/Sources/agentc/Commands/MigrateFromClaudecCommand.swift similarity index 95% rename from Sources/agentc/MigrateFromClaudecCommand.swift rename to Sources/agentc/Commands/MigrateFromClaudecCommand.swift index 60a1935..88e13d6 100644 --- a/Sources/agentc/MigrateFromClaudecCommand.swift +++ b/Sources/agentc/Commands/MigrateFromClaudecCommand.swift @@ -1,5 +1,10 @@ import ArgumentParser -import Foundation + +#if canImport(FoundationEssentials) + import FoundationEssentials +#else + import Foundation +#endif struct MigrateFromClaudecCommand: ParsableCommand { static let configuration = CommandConfiguration( diff --git a/Sources/agentc/Commands/RunCommand.swift b/Sources/agentc/Commands/RunCommand.swift new file mode 100644 index 0000000..c85b97b --- /dev/null +++ b/Sources/agentc/Commands/RunCommand.swift @@ -0,0 +1,44 @@ +import ArgumentParser + +#if canImport(FoundationEssentials) + import FoundationEssentials +#else + import Foundation +#endif + +struct RunCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "run", + abstract: "Run an agent in an isolated container", + discussion: """ + Start an agent session using the specified (or default) configurations. \ + Arguments after '--' are forwarded to the configuration's entrypoint. + + Examples: + agentc run # default configurations + agentc run -c claude # use 'claude' configuration + agentc run -c claude,copilot # multiple configurations + agentc run -c claude -- --model opus # forward args to entrypoint + """ + ) + + @OptionGroup var options: SharedOptions + + @Argument(parsing: .remaining, help: "Arguments forwarded to the entrypoint.") + var entrypointArguments: [String] = [] + + mutating func run() async throws { + let projectSettings = options.loadProjectSettings() + let allocateTTY = isatty(STDIN_FILENO) == 1 && isatty(STDOUT_FILENO) == 1 + let resolvedArguments = options.resolveArguments( + entrypointArguments: entrypointArguments, projectSettings: projectSettings) + + let exitCode = try await SessionRunner.run( + options: options, + configurationsPositional: options.configurationsFlag, + allocateTTY: allocateTTY, + arguments: resolvedArguments + ) + throw ExitCode(exitCode) + } +} diff --git a/Sources/agentc/Commands/ShellCommand.swift b/Sources/agentc/Commands/ShellCommand.swift new file mode 100644 index 0000000..f7aa543 --- /dev/null +++ b/Sources/agentc/Commands/ShellCommand.swift @@ -0,0 +1,55 @@ +import ArgumentParser + +#if canImport(FoundationEssentials) + import FoundationEssentials +#else + import Foundation +#endif + +struct ShellCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "sh", + abstract: "Open a shell or run a command inside the container", + discussion: """ + Without arguments, opens an interactive bash shell. With arguments, runs the specified command. + + Examples: + agentc sh # interactive shell + agentc sh echo hello # run a command + agentc sh -- ls -la /home/agent # run with flags + agentc sh -c claude cat file.txt # specific configuration + """ + ) + + @OptionGroup var options: SharedOptions + + @Argument(parsing: .remaining, help: "Command and arguments to run.") + var command: [String] = [] + + mutating func run() async throws { + // sh is interactive when no command is given + let allocateTTY: Bool + if command.isEmpty { + allocateTTY = isatty(STDIN_FILENO) == 1 && isatty(STDOUT_FILENO) == 1 + } else { + allocateTTY = false + } + + // Build the entrypoint override for shell dispatch + let entrypointOverride: [String] + if command.isEmpty { + entrypointOverride = ["/bin/bash"] + } else { + entrypointOverride = ["/bin/bash", "-c", command.joined(separator: " ")] + } + + let exitCode = try await SessionRunner.run( + options: options, + configurationsPositional: nil, + allocateTTY: allocateTTY, + arguments: [], + entrypoint: entrypointOverride + ) + throw ExitCode(exitCode) + } +} diff --git a/Sources/agentc/VersionCommand.swift b/Sources/agentc/Commands/VersionCommand.swift similarity index 100% rename from Sources/agentc/VersionCommand.swift rename to Sources/agentc/Commands/VersionCommand.swift diff --git a/Sources/agentc/ConfigurationsManager.swift b/Sources/agentc/ConfigurationsManager.swift index 11a5015..76e43e1 100644 --- a/Sources/agentc/ConfigurationsManager.swift +++ b/Sources/agentc/ConfigurationsManager.swift @@ -1,4 +1,8 @@ -import Foundation +#if canImport(FoundationEssentials) + import FoundationEssentials +#else + import Foundation +#endif /// Manages the agent-isolation-configurations git repository (clone / pull). enum ConfigurationsManager { diff --git a/Sources/agentc/MigrationCheck.swift b/Sources/agentc/MigrationCheck.swift index 10b4fec..6dfb1ba 100644 --- a/Sources/agentc/MigrationCheck.swift +++ b/Sources/agentc/MigrationCheck.swift @@ -1,5 +1,10 @@ import ArgumentParser -import Foundation + +#if canImport(FoundationEssentials) + import FoundationEssentials +#else + import Foundation +#endif /// Checks whether the user should migrate from the legacy ~/.claudec directory. /// diff --git a/Sources/agentc/RuntimeResolver.swift b/Sources/agentc/RuntimeResolver.swift index e43421f..063d9f0 100644 --- a/Sources/agentc/RuntimeResolver.swift +++ b/Sources/agentc/RuntimeResolver.swift @@ -1,5 +1,10 @@ import ArgumentParser -import Foundation + +#if canImport(FoundationEssentials) + import FoundationEssentials +#else + import Foundation +#endif /// Determine which container runtime to use. enum RuntimeChoice: String, ExpressibleByArgument, CaseIterable, Sendable { diff --git a/Sources/agentc/RunCommand.swift b/Sources/agentc/SessionRunner.swift similarity index 61% rename from Sources/agentc/RunCommand.swift rename to Sources/agentc/SessionRunner.swift index e4135b9..f9ba962 100644 --- a/Sources/agentc/RunCommand.swift +++ b/Sources/agentc/SessionRunner.swift @@ -1,7 +1,10 @@ import AgentIsolation -import ArgumentParser -import Foundation -import Logging + +#if canImport(FoundationEssentials) + import FoundationEssentials +#else + import Foundation +#endif #if ContainerRuntimeAppleContainer import AgentIsolationAppleContainerRuntime @@ -10,28 +13,26 @@ import Logging import AgentIsolationDockerRuntime #endif -struct RunCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "run", - abstract: "Run an agent in an isolated container", - discussion: """ - Start an agent session using the specified (or default) configurations. \ - Arguments after '--' are forwarded to the configuration's entrypoint. - - Examples: - agentc run # default configurations - agentc run -c claude # use 'claude' configuration - agentc run -c claude,copilot # multiple configurations - agentc run -c claude -- --model opus # forward args to entrypoint - """ - ) - - @OptionGroup var options: SharedOptions - - @Argument(parsing: .remaining, help: "Arguments forwarded to the entrypoint.") - var entrypointArguments: [String] = [] - - mutating func run() async throws { +/// Shared logic for commands that resolve configuration, set up a container +/// runtime, and run an agent session. +enum SessionRunner { + + /// Resolve all configuration from `options`, set up the container runtime, + /// and run an agent session. + /// + /// - Parameters: + /// - options: The shared CLI options. + /// - configurationsPositional: Optional positional override for configuration names. + /// - allocateTTY: Whether to attach a TTY to the container. + /// - arguments: Pre-resolved arguments forwarded to the entrypoint. + /// - entrypoint: Optional entrypoint override (e.g. for shell commands). + static func run( + options: SharedOptions, + configurationsPositional: String?, + allocateTTY: Bool, + arguments: [String], + entrypoint: [String]? = nil + ) async throws -> Int32 { // Check for legacy claudec data before proceeding try MigrationCheck.checkIfNeeded(suppress: options.suppressMigrationFromClaudec) @@ -42,10 +43,9 @@ struct RunCommand: AsyncParsableCommand { let workspace = options.resolveWorkspace() let configurationsDir = options.resolveConfigurationsDir() let configNames = options.resolveConfigurations( - positional: options.configurationsFlag, profileDir: profileDir, + positional: configurationsPositional, profileDir: profileDir, projectSettings: projectSettings) let excludeFolders = options.resolveExcludeFolders(projectSettings: projectSettings) - let allocateTTY = isatty(STDIN_FILENO) == 1 && isatty(STDOUT_FILENO) == 1 // Ensure configurations repo try ConfigurationsManager.ensureRepo( @@ -55,8 +55,6 @@ struct RunCommand: AsyncParsableCommand { ) let resolvedImage = options.resolveImage(projectSettings: projectSettings) - let resolvedArguments = options.resolveArguments( - entrypointArguments: entrypointArguments, projectSettings: projectSettings) let isolationConfig = IsolationConfig( image: resolvedImage, @@ -66,7 +64,7 @@ struct RunCommand: AsyncParsableCommand { configurationsDir: configurationsDir, configurations: configNames, bootstrapMode: try options.resolveBootstrapMode(projectSettings: projectSettings), - arguments: resolvedArguments, + arguments: arguments, allocateTTY: allocateTTY, cpuCount: options.resolveCpuCount(projectSettings: projectSettings), memoryLimitMiB: options.resolveMemoryLimitMiB(projectSettings: projectSettings), @@ -74,12 +72,17 @@ struct RunCommand: AsyncParsableCommand { verbose: options.verbose ) - let exitCode = try await runSession(config: isolationConfig) - throw ExitCode(exitCode) + return try await dispatchToRuntime( + options: options, config: isolationConfig, entrypoint: entrypoint) } - /// Set up the runtime, optionally update the image, and run the session. - private func runSession(config: IsolationConfig) async throws -> Int32 { + // MARK: - Runtime dispatch + + private static func dispatchToRuntime( + options: SharedOptions, + config: IsolationConfig, + entrypoint: [String]? + ) async throws -> Int32 { let storagePath = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask) .first! @@ -93,23 +96,32 @@ struct RunCommand: AsyncParsableCommand { return switch choice { case .docker: #if ContainerRuntimeDocker - try await runSessionWithRuntime( - DockerRuntime(config: runtimeConfig), config: config) + try await executeSession( + runtime: DockerRuntime(config: runtimeConfig), + config: config, + options: options, + entrypoint: entrypoint) #else throw AgentcError.runtimeNotAvailable("docker") #endif case .appleContainer: #if ContainerRuntimeAppleContainer - try await runSessionWithRuntime( - AppleContainerRuntime(config: runtimeConfig), config: config) + try await executeSession( + runtime: AppleContainerRuntime(config: runtimeConfig), + config: config, + options: options, + entrypoint: entrypoint) #else throw AgentcError.runtimeNotAvailable("apple-container") #endif } } - private func runSessionWithRuntime( - _ runtime: R, config: IsolationConfig + private static func executeSession( + runtime: R, + config: IsolationConfig, + options: SharedOptions, + entrypoint: [String]? ) async throws -> Int32 { defer { Task { try? await runtime.shutdown() } } if options.updateImage { @@ -126,6 +138,10 @@ struct RunCommand: AsyncParsableCommand { } } let session = AgentSession(config: config, runtime: runtime) - return try await session.run() + if let entrypoint { + return try await session.run(entrypoint: entrypoint) + } else { + return try await session.run() + } } } diff --git a/Sources/agentc/SharedOptions.swift b/Sources/agentc/SharedOptions.swift index d48bbea..15b542e 100644 --- a/Sources/agentc/SharedOptions.swift +++ b/Sources/agentc/SharedOptions.swift @@ -1,6 +1,11 @@ import AgentIsolation import ArgumentParser -import Foundation + +#if canImport(FoundationEssentials) + import FoundationEssentials +#else + import Foundation +#endif struct SharedOptions: ParsableArguments { @Option(name: .shortAndLong, help: "Container runtime.") diff --git a/Sources/agentc/ShellCommand.swift b/Sources/agentc/ShellCommand.swift deleted file mode 100644 index 9162c10..0000000 --- a/Sources/agentc/ShellCommand.swift +++ /dev/null @@ -1,131 +0,0 @@ -import AgentIsolation -import ArgumentParser -import Foundation -import Logging - -#if ContainerRuntimeAppleContainer - import AgentIsolationAppleContainerRuntime -#endif -#if ContainerRuntimeDocker - import AgentIsolationDockerRuntime -#endif - -struct ShellCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "sh", - abstract: "Open a shell or run a command inside the container", - discussion: """ - Without arguments, opens an interactive bash shell. With arguments, runs the specified command. - - Examples: - agentc sh # interactive shell - agentc sh echo hello # run a command - agentc sh -- ls -la /home/agent # run with flags - agentc sh -c claude cat file.txt # specific configuration - """ - ) - - @OptionGroup var options: SharedOptions - - @Argument(parsing: .remaining, help: "Command and arguments to run.") - var command: [String] = [] - - mutating func run() async throws { - // Check for legacy claudec data before proceeding - try MigrationCheck.checkIfNeeded(suppress: options.suppressMigrationFromClaudec) - - let projectSettings = options.loadProjectSettings() - - let (_, profileDir) = options.resolveProfile(projectSettings: projectSettings) - let profileHomeDir = profileDir.appending(path: "home") - let workspace = options.resolveWorkspace() - let configurationsDir = options.resolveConfigurationsDir() - let configNames = options.resolveConfigurations( - positional: nil, profileDir: profileDir, projectSettings: projectSettings) - let excludeFolders = options.resolveExcludeFolders(projectSettings: projectSettings) - - // sh is interactive when no command is given - let allocateTTY: Bool - if command.isEmpty { - allocateTTY = isatty(STDIN_FILENO) == 1 && isatty(STDOUT_FILENO) == 1 - } else { - allocateTTY = false - } - - // Ensure configurations repo - try ConfigurationsManager.ensureRepo( - at: configurationsDir, - repoURL: options.configurationsRepo, - updateInterval: options.configurationsUpdateInterval - ) - - // Build the entrypoint override for shell dispatch - let entrypointOverride: [String] - if command.isEmpty { - entrypointOverride = ["/bin/bash"] - } else { - entrypointOverride = ["/bin/bash", "-c", command.joined(separator: " ")] - } - - let resolvedImage = options.resolveImage(projectSettings: projectSettings) - - let isolationConfig = IsolationConfig( - image: resolvedImage, - profileHomeDir: profileHomeDir, - workspace: workspace, - excludeFolders: excludeFolders, - configurationsDir: configurationsDir, - configurations: configNames, - bootstrapMode: try options.resolveBootstrapMode(projectSettings: projectSettings), - arguments: [], - allocateTTY: allocateTTY, - cpuCount: options.resolveCpuCount(projectSettings: projectSettings), - memoryLimitMiB: options.resolveMemoryLimitMiB(projectSettings: projectSettings), - additionalHostMounts: options.resolveAdditionalMounts(projectSettings: projectSettings), - verbose: options.verbose - ) - - let exitCode = try await runShellSession( - config: isolationConfig, entrypoint: entrypointOverride) - throw ExitCode(exitCode) - } - - private func runShellSession( - config: IsolationConfig, entrypoint: [String] - ) async throws -> Int32 { - let storagePath = - FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask) - .first! - .appendingPathComponent("sb.lao.agentc") - .path - - let runtimeConfig = ContainerRuntimeConfiguration( - storagePath: storagePath, endpoint: options.dockerEndpoint) - - let choice = RuntimeChoice.resolve(explicit: options.runtime) - return switch choice { - case .docker: - #if ContainerRuntimeDocker - try await runShellSessionWithRuntime( - DockerRuntime(config: runtimeConfig), config: config, entrypoint: entrypoint) - #else - throw AgentcError.runtimeNotAvailable("docker") - #endif - case .appleContainer: - #if ContainerRuntimeAppleContainer - try await runShellSessionWithRuntime( - AppleContainerRuntime(config: runtimeConfig), config: config, entrypoint: entrypoint) - #else - throw AgentcError.runtimeNotAvailable("apple-container") - #endif - } - } - - private func runShellSessionWithRuntime( - _ runtime: R, config: IsolationConfig, entrypoint: [String] - ) async throws -> Int32 { - defer { Task { try? await runtime.shutdown() } } - let session = AgentSession(config: config, runtime: runtime) - return try await session.run(entrypoint: entrypoint) - } -} From 6867b2b8368dc774ae8f028035b0f56f0ce61a0b Mon Sep 17 00:00:00 2001 From: laosb Date: Thu, 16 Apr 2026 18:16:49 +0800 Subject: [PATCH 2/3] feat: init command. --- README.md | 16 +- Sources/AgentIsolation/ProjectSettings.swift | 37 ++- Sources/agentc/Commands/AgentcCommand.swift | 8 +- Sources/agentc/Commands/InitCommand.swift | 134 +++++++++ .../InitCommandIntegrationTests.swift | 276 ++++++++++++++++++ 5 files changed, 452 insertions(+), 19 deletions(-) create mode 100644 Sources/agentc/Commands/InitCommand.swift create mode 100644 Tests/AgentcIntegrationTests/InitCommandIntegrationTests.swift diff --git a/README.md b/README.md index b502893..4c42891 100644 --- a/README.md +++ b/README.md @@ -56,21 +56,7 @@ agentc run -c copilot ### Project Settings -Place a `.agentc/settings.json` file in your project root to set default agent options for the project. - -```json -{ - "agent": { - "image": "ghcr.io/my-org/dev:latest", - "configurations": ["claude"], - "cpus": 4, - "memoryMiB": 4096, - "excludes": [".git", "secrets"] - } -} -``` - -CLI flags override project settings; some fields (like `excludes` and `additionalMounts`) are merged. +Use `agentc init` to place a `.agentc/settings.json` file in your project root to set default agent options for the project. CLI flags override project settings; some fields (like `excludes` and `additionalMounts`) are merged. See [docs/project-settings.md](./docs/project-settings.md) for the full schema and override rules. diff --git a/Sources/AgentIsolation/ProjectSettings.swift b/Sources/AgentIsolation/ProjectSettings.swift index 2090dcf..08cf021 100644 --- a/Sources/AgentIsolation/ProjectSettings.swift +++ b/Sources/AgentIsolation/ProjectSettings.swift @@ -9,10 +9,14 @@ /// Place this file in your project root (or any ancestor directory) to set defaults /// for `agentc` invocations within the project tree. All fields are optional; /// only the values you specify take effect. -public struct ProjectSettings: Decodable, Sendable, Equatable { +public struct ProjectSettings: Codable, Sendable, Equatable { public var agent: AgentSettings? - public struct AgentSettings: Decodable, Sendable, Equatable { + public init(agent: AgentSettings? = nil) { + self.agent = agent + } + + public struct AgentSettings: Codable, Sendable, Equatable { public var image: String? public var profile: String? public var excludes: [String]? @@ -24,6 +28,32 @@ public struct ProjectSettings: Decodable, Sendable, Equatable { public var memoryMiB: Int? public var bootstrap: String? public var respectImageEntrypoint: Bool? + + public init( + image: String? = nil, + profile: String? = nil, + excludes: [String]? = nil, + configurations: [String]? = nil, + additionalMounts: [String]? = nil, + defaultArguments: [String]? = nil, + additionalArguments: [String]? = nil, + cpus: Int? = nil, + memoryMiB: Int? = nil, + bootstrap: String? = nil, + respectImageEntrypoint: Bool? = nil + ) { + self.image = image + self.profile = profile + self.excludes = excludes + self.configurations = configurations + self.additionalMounts = additionalMounts + self.defaultArguments = defaultArguments + self.additionalArguments = additionalArguments + self.cpus = cpus + self.memoryMiB = memoryMiB + self.bootstrap = bootstrap + self.respectImageEntrypoint = respectImageEntrypoint + } } /// The folder names to probe at each directory level, in priority order. @@ -38,7 +68,8 @@ public struct ProjectSettings: Decodable, Sendable, Equatable { var dir = startDir.standardizedFileURL while true { for folderName in folderNames { - let settingsURL = dir + let settingsURL = + dir .appendingPathComponent(folderName) .appendingPathComponent("settings.json") if let settings = load(from: settingsURL) { diff --git a/Sources/agentc/Commands/AgentcCommand.swift b/Sources/agentc/Commands/AgentcCommand.swift index 007fd63..cf82ed2 100644 --- a/Sources/agentc/Commands/AgentcCommand.swift +++ b/Sources/agentc/Commands/AgentcCommand.swift @@ -13,7 +13,13 @@ struct AgentcCommand: AsyncParsableCommand { The simplest invocation is just `agentc run`. Use `agentc --help` for \ full details on each subcommand. """, - subcommands: [RunCommand.self, ShellCommand.self, VersionCommand.self, MigrateFromClaudecCommand.self], + subcommands: [ + RunCommand.self, + ShellCommand.self, + InitCommand.self, + VersionCommand.self, + MigrateFromClaudecCommand.self, + ], defaultSubcommand: RunCommand.self ) } diff --git a/Sources/agentc/Commands/InitCommand.swift b/Sources/agentc/Commands/InitCommand.swift new file mode 100644 index 0000000..cf92109 --- /dev/null +++ b/Sources/agentc/Commands/InitCommand.swift @@ -0,0 +1,134 @@ +import AgentIsolation +import ArgumentParser + +#if canImport(FoundationEssentials) + import FoundationEssentials +#else + import Foundation +#endif + +struct InitCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "init", + abstract: "Initialize agentc project settings and container environment", + discussion: """ + Creates a `.agentc/settings.json` file and optionally runs the container \ + to ensure all configurations are loaded and prepared. + + Examples: + agentc init # init in current directory + agentc init ~/myproject # init in specified directory + agentc init --skip-container-init # settings only, no container + agentc init --skip-project-settings # container only, no settings + agentc init --cpus 4 --memory-mib 4096 # init with custom resources + """ + ) + + @OptionGroup var options: SharedOptions + + @Argument(help: "Directory to initialize (default: current directory).") + var directory: String? + + @Flag(name: .customLong("skip-project-settings"), help: "Skip creating .agentc/settings.json.") + var skipProjectSettings: Bool = false + + @Flag( + name: .customLong("skip-container-init"), + help: "Skip running the container to prepare configurations.") + var skipContainerInit: Bool = false + + mutating func run() async throws { + let targetDir: URL + if let dir = directory { + targetDir = URL(fileURLWithPath: dir, isDirectory: true) + } else { + targetDir = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + } + + // Step 1: Create .agentc/settings.json + if !skipProjectSettings { + try createProjectSettings(in: targetDir) + print( + "agentc: Created \(targetDir.appendingPathComponent(".agentc/settings.json").path)") + } + + // Step 2: Container initialization — run through the full bootstrap so that + // configurations are loaded and prepare.sh scripts execute, then exit + // immediately via a trivial entrypoint. + if !skipContainerInit { + print("agentc: Initializing container environment...") + do { + let exitCode = try await SessionRunner.run( + options: options, + configurationsPositional: nil, + allocateTTY: false, + arguments: [], + entrypoint: ["/bin/sh", "-c", "true"] + ) + if exitCode == 0 { + print("agentc: Container environment initialized successfully.") + } else { + print("agentc: Warning: container initialization exited with code \(exitCode).") + } + } catch { + print( + "agentc: Warning: container initialization failed: \(error.localizedDescription)") + } + } + + // Step 3: Getting-started info + print() + print("Project initialized! Get started with:") + print() + print(" agentc run Start the agent") + print(" agentc sh Open a shell in the container") + print(" agentc --help Show all available options") + print() + } + + // MARK: - Settings file creation + + private func createProjectSettings(in targetDir: URL) throws { + let agentcDir = targetDir.appendingPathComponent(".agentc") + try FileManager.default.createDirectory(at: agentcDir, withIntermediateDirectories: true) + + let settings = buildSettings() + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(settings) + + try data.write(to: agentcDir.appendingPathComponent("settings.json")) + } + + private func buildSettings() -> ProjectSettings { + let configurations: [String] + if let raw = options.configurationsFlag, !raw.isEmpty { + configurations = raw.split(separator: ",").map { + String($0).trimmingCharacters(in: .whitespaces) + } + } else { + configurations = ["claude"] + } + + let excludes: [String]? + if let raw = options.excludeFolders, !raw.isEmpty { + excludes = raw.split(separator: ",").map(String.init) + } else { + excludes = nil + } + + return ProjectSettings( + agent: .init( + image: options.image ?? "ghcr.io/laosb/claudec:latest", + profile: options.profile, + excludes: excludes, + configurations: configurations, + additionalMounts: options.additionalMount.isEmpty ? nil : options.additionalMount, + cpus: options.cpuCount ?? 1, + memoryMiB: options.memoryLimitMiB ?? 1536, + bootstrap: options.bootstrapScript, + respectImageEntrypoint: options.respectImageEntrypoint ? true : nil + ) + ) + } +} diff --git a/Tests/AgentcIntegrationTests/InitCommandIntegrationTests.swift b/Tests/AgentcIntegrationTests/InitCommandIntegrationTests.swift new file mode 100644 index 0000000..f6acada --- /dev/null +++ b/Tests/AgentcIntegrationTests/InitCommandIntegrationTests.swift @@ -0,0 +1,276 @@ +import Foundation +import Testing + +@Suite("Init Command Integration Tests") +struct InitCommandIntegrationTests { + init() { + _ = sharedProfile + } + + // MARK: - Settings file creation + + @Test("agentc init creates .agentc/settings.json with defaults") + func initCreatesSettingsWithDefaults() async throws { + let base = URL(fileURLWithPath: "/tmp/__TEST_agentc_init.\(UUID().uuidString.prefix(6))") + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + let result = await runAgentc( + args: [ + "init", + base.path, + "--skip-container-init", + ] + ) + #expect(result.exitCode == 0) + + let settingsPath = base.appendingPathComponent(".agentc/settings.json") + #expect(FileManager.default.fileExists(atPath: settingsPath.path)) + + let data = try Data(contentsOf: settingsPath) + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + let agent = json["agent"] as! [String: Any] + #expect(agent["image"] as? String == "ghcr.io/laosb/claudec:latest") + #expect(agent["configurations"] as? [String] == ["claude"]) + #expect(agent["cpus"] as? Int == 1) + #expect(agent["memoryMiB"] as? Int == 1536) + } + + @Test("agentc init with custom options writes them to settings") + func initWithCustomOptions() async throws { + let base = URL( + fileURLWithPath: "/tmp/__TEST_agentc_init_opts.\(UUID().uuidString.prefix(6))") + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + let result = await runAgentc( + args: [ + "init", + base.path, + "--skip-container-init", + "--cpus", "4", + "--memory-mib", "4096", + "--image", "custom:latest", + "--configurations", "claude,copilot", + "--exclude", "node_modules,.git", + ] + ) + #expect(result.exitCode == 0) + + let settingsPath = base.appendingPathComponent(".agentc/settings.json") + let data = try Data(contentsOf: settingsPath) + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + let agent = json["agent"] as! [String: Any] + #expect(agent["image"] as? String == "custom:latest") + #expect(agent["cpus"] as? Int == 4) + #expect(agent["memoryMiB"] as? Int == 4096) + #expect(agent["configurations"] as? [String] == ["claude", "copilot"]) + #expect(agent["excludes"] as? [String] == ["node_modules", ".git"]) + } + + @Test("agentc init with --profile writes profile to settings") + func initWithProfile() async throws { + let base = URL( + fileURLWithPath: "/tmp/__TEST_agentc_init_prof.\(UUID().uuidString.prefix(6))") + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + let result = await runAgentc( + args: [ + "init", + base.path, + "--skip-container-init", + "--profile", "work", + ] + ) + #expect(result.exitCode == 0) + + let settingsPath = base.appendingPathComponent(".agentc/settings.json") + let data = try Data(contentsOf: settingsPath) + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + let agent = json["agent"] as! [String: Any] + #expect(agent["profile"] as? String == "work") + } + + // MARK: - Skip flags + + @Test("agentc init --skip-project-settings does not create settings file") + func skipProjectSettings() async throws { + let base = URL( + fileURLWithPath: "/tmp/__TEST_agentc_init_skipps.\(UUID().uuidString.prefix(6))") + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + let result = await runAgentc( + args: [ + "init", + base.path, + "--skip-project-settings", + "--skip-container-init", + ] + ) + #expect(result.exitCode == 0) + + let settingsPath = base.appendingPathComponent(".agentc/settings.json") + #expect(!FileManager.default.fileExists(atPath: settingsPath.path)) + } + + // MARK: - Directory argument + + @Test("agentc init without dir argument uses current working directory") + func initUsesCurrentDirectory() async throws { + let base = URL( + fileURLWithPath: "/tmp/__TEST_agentc_init_cwd.\(UUID().uuidString.prefix(6))") + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + let result = await runAgentc( + args: [ + "init", + "--skip-container-init", + ], + cwd: base.path + ) + #expect(result.exitCode == 0) + + let settingsPath = base.appendingPathComponent(".agentc/settings.json") + #expect(FileManager.default.fileExists(atPath: settingsPath.path)) + } + + @Test("agentc init creates settings in specified directory") + func initCreatesSettingsInSpecifiedDir() async throws { + let base = URL( + fileURLWithPath: "/tmp/__TEST_agentc_init_dir.\(UUID().uuidString.prefix(6))") + let targetDir = base.appendingPathComponent("myproject") + try FileManager.default.createDirectory(at: targetDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + let result = await runAgentc( + args: [ + "init", + targetDir.path, + "--skip-container-init", + ] + ) + #expect(result.exitCode == 0) + + let settingsPath = targetDir.appendingPathComponent(".agentc/settings.json") + #expect(FileManager.default.fileExists(atPath: settingsPath.path)) + } + + // MARK: - Output messages + + @Test("agentc init prints getting-started info") + func initPrintsInfo() async throws { + let base = URL( + fileURLWithPath: "/tmp/__TEST_agentc_init_info.\(UUID().uuidString.prefix(6))") + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + let result = await runAgentc( + args: [ + "init", + base.path, + "--skip-container-init", + ] + ) + #expect(result.exitCode == 0) + #expect(result.output.contains("agentc run")) + #expect(result.output.contains("agentc sh")) + } + + @Test("agentc init prints settings file creation message") + func initPrintsSettingsCreationMessage() async throws { + let base = URL( + fileURLWithPath: "/tmp/__TEST_agentc_init_msg.\(UUID().uuidString.prefix(6))") + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + let result = await runAgentc( + args: [ + "init", + base.path, + "--skip-container-init", + ] + ) + #expect(result.exitCode == 0) + #expect(result.output.contains(".agentc/settings.json")) + } + + // MARK: - Settings roundtrip + + @Test("Generated settings file is loadable and applied by agentc") + func settingsRoundtrip() async throws { + let base = URL( + fileURLWithPath: "/tmp/__TEST_agentc_init_rt.\(UUID().uuidString.prefix(6))") + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + // Create settings with cpus=2 + let initResult = await runAgentc( + args: [ + "init", + base.path, + "--skip-container-init", + "--cpus", "2", + ] + ) + #expect(initResult.exitCode == 0) + + // Verify agentc picks up the generated settings via CWD discovery + let verifyResult = await runAgentc( + args: [ + "sh", + "--profile", sharedProfile, + "--configurations-dir", sharedConfigurationsDir, + "--no-update-image", + "--", "nproc", + ], + cwd: base.path + ) + #expect(verifyResult.exitCode == 0) + let reported = verifyResult.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(reported == "2") + } + + // MARK: - Container initialization + + @Test("agentc init runs container initialization") + func initRunsContainer() async throws { + let base = URL( + fileURLWithPath: "/tmp/__TEST_agentc_init_cont.\(UUID().uuidString.prefix(6))") + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + let result = await runAgentc( + args: [ + "init", + base.path, + "--profile", sharedProfile, + "--configurations-dir", sharedConfigurationsDir, + "--no-update-image", + "--skip-project-settings", + ] + ) + #expect(result.exitCode == 0) + #expect(result.output.contains("Container environment initialized")) + } + + @Test("agentc init --skip-container-init does not run container") + func skipContainerInit() async throws { + let base = URL( + fileURLWithPath: "/tmp/__TEST_agentc_init_skipc.\(UUID().uuidString.prefix(6))") + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + let result = await runAgentc( + args: [ + "init", + base.path, + "--skip-container-init", + ] + ) + #expect(result.exitCode == 0) + #expect(!result.output.contains("Initializing container environment")) + } +} From 051bcc766ac4a2c4b7a31dedbc5e6a73916a5724 Mon Sep 17 00:00:00 2001 From: laosb Date: Thu, 16 Apr 2026 22:22:44 +0800 Subject: [PATCH 3/3] refactor: use swift-subprocess & swift-system. --- Package.resolved | 11 ++- Package.swift | 14 ++- .../AppleContainerRuntime.swift | 7 +- .../FileDescriptorIO.swift | 62 ++++++++++++ .../FileHandleIO.swift | 44 --------- .../DockerAPIClient.swift | 25 ++++- .../DockerRuntime.swift | 15 +++ .../DockerStreamAttach.swift | 29 +++--- Sources/agentc/BootstrapManager.swift | 53 ++++++---- Sources/agentc/Commands/RunCommand.swift | 8 ++ Sources/agentc/Commands/ShellCommand.swift | 8 ++ Sources/agentc/ConfigurationsManager.swift | 44 ++++++--- Sources/agentc/MigrationCheck.swift | 13 ++- Sources/agentc/SessionRunner.swift | 5 +- Sources/agentc/SharedOptions.swift | 4 +- Sources/agentc/StderrWriter.swift | 16 +++ .../AgentcHelpers.swift | 97 ++++++++----------- .../AgentcIntegrationTests.swift | 6 +- 18 files changed, 290 insertions(+), 171 deletions(-) create mode 100644 Sources/AgentIsolationAppleContainerRuntime/FileDescriptorIO.swift delete mode 100644 Sources/AgentIsolationAppleContainerRuntime/FileHandleIO.swift create mode 100644 Sources/agentc/StderrWriter.swift diff --git a/Package.resolved b/Package.resolved index 526b9e7..3defc61 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "4d1818384bf1e5e6200d78f16d960f120ec649e30c0b39c2fe9ea67b629df81b", + "originHash" : "8fe1adf3b3215cbf2b7f700361b6095212c20861113618d5cbc43351469c58e0", "pins" : [ { "identity" : "async-http-client", @@ -226,6 +226,15 @@ "version" : "2.11.0" } }, + { + "identity" : "swift-subprocess", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-subprocess.git", + "state" : { + "revision" : "13d087685b95d64d6aac9b94500d347bbe84c39b", + "version" : "0.4.0" + } + }, { "identity" : "swift-system", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 239beba..d4edb2e 100644 --- a/Package.swift +++ b/Package.swift @@ -27,10 +27,12 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/containerization.git", from: "0.30.0"), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.7.0"), - .package(url: "https://github.com/apple/swift-crypto.git", "1.0.0" ..< "5.0.0"), + .package(url: "https://github.com/apple/swift-crypto.git", "1.0.0"..<"5.0.0"), .package(url: "https://github.com/apple/swift-log.git", from: "1.12.0"), .package(url: "https://github.com/apple/swift-nio.git", from: "2.97.0"), + .package(url: "https://github.com/apple/swift-system.git", from: "1.6.4"), .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.33.1"), + .package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.4.0"), ], targets: [ .target( @@ -66,6 +68,8 @@ let package = Package( .product(name: "NIOCore", package: "swift-nio"), .product(name: "NIOFoundationCompat", package: "swift-nio"), .product(name: "Logging", package: "swift-log"), + .product( + name: "SystemPackage", package: "swift-system", condition: .when(platforms: [.linux])), ] ), .executableTarget( @@ -79,6 +83,9 @@ let package = Package( name: "AgentIsolationDockerRuntime", condition: .when(traits: ["ContainerRuntimeDocker"])), .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "Logging", package: "swift-log"), + .product(name: "Subprocess", package: "swift-subprocess"), + .product( + name: "SystemPackage", package: "swift-system", condition: .when(platforms: [.linux])), ] ), .executableTarget( @@ -112,7 +119,10 @@ let package = Package( .testTarget( name: "AgentcIntegrationTests", dependencies: [ - .product(name: "Crypto", package: "swift-crypto") + .product(name: "Crypto", package: "swift-crypto"), + .product(name: "Subprocess", package: "swift-subprocess"), + .product( + name: "SystemPackage", package: "swift-system", condition: .when(platforms: [.linux])), ] ), ] diff --git a/Sources/AgentIsolationAppleContainerRuntime/AppleContainerRuntime.swift b/Sources/AgentIsolationAppleContainerRuntime/AppleContainerRuntime.swift index a666ab1..70713cd 100644 --- a/Sources/AgentIsolationAppleContainerRuntime/AppleContainerRuntime.swift +++ b/Sources/AgentIsolationAppleContainerRuntime/AppleContainerRuntime.swift @@ -6,6 +6,7 @@ import ContainerizationOS import Foundation import Logging + import System // MARK: - AppleContainerRuntime @@ -164,9 +165,9 @@ containerConfig.process.setTerminalIO(terminal: t) } case .standardIO: - containerConfig.process.stdin = FileHandleReader(.standardInput) - containerConfig.process.stdout = FileHandleWriter(.standardOutput) - containerConfig.process.stderr = FileHandleWriter(.standardError) + containerConfig.process.stdin = FileDescriptorReader(.standardInput) + containerConfig.process.stdout = FileDescriptorWriter(.standardOutput) + containerConfig.process.stderr = FileDescriptorWriter(.standardError) case .custom(let stdin, let stdout, let stderr, let isTerminal): containerConfig.process.terminal = isTerminal containerConfig.process.stdin = ContainerizationReaderStream(stdin) diff --git a/Sources/AgentIsolationAppleContainerRuntime/FileDescriptorIO.swift b/Sources/AgentIsolationAppleContainerRuntime/FileDescriptorIO.swift new file mode 100644 index 0000000..4875506 --- /dev/null +++ b/Sources/AgentIsolationAppleContainerRuntime/FileDescriptorIO.swift @@ -0,0 +1,62 @@ +#if canImport(Containerization) + import Containerization + import Foundation + import System + + #if canImport(Darwin) + import Darwin + #elseif canImport(Glibc) + import Glibc + #endif + + /// `ReaderStream` backed by a `FileDescriptor` (e.g. stdin). + struct FileDescriptorReader: ReaderStream { + private let fd: FileDescriptor + + init(_ fd: FileDescriptor) { + self.fd = fd + } + + func stream() -> AsyncStream { + let rawFD = fd.rawValue + return AsyncStream { continuation in + let source = DispatchSource.makeReadSource( + fileDescriptor: rawFD, + queue: DispatchQueue.global(qos: .userInteractive)) + source.setEventHandler { + var buffer = [UInt8](repeating: 0, count: 4096) + let bytesRead = read(rawFD, &buffer, buffer.count) + if bytesRead <= 0 { + source.cancel() + } else { + continuation.yield(Data(buffer[0.. AsyncStream { - AsyncStream { continuation in - handle.readabilityHandler = { h in - let data = h.availableData - if data.isEmpty { - h.readabilityHandler = nil - continuation.finish() - } else { - continuation.yield(data) - } - } - } - } - } - - /// `Writer` backed by a `FileHandle` (e.g. stdout / stderr). - struct FileHandleWriter: Writer { - private let handle: FileHandle - - init(_ handle: FileHandle) { - self.handle = handle - } - - func write(_ data: Data) throws { - try handle.write(contentsOf: data) - } - - func close() throws { - try handle.close() - } - } -#endif diff --git a/Sources/AgentIsolationDockerRuntime/DockerAPIClient.swift b/Sources/AgentIsolationDockerRuntime/DockerAPIClient.swift index 454af22..2718315 100644 --- a/Sources/AgentIsolationDockerRuntime/DockerAPIClient.swift +++ b/Sources/AgentIsolationDockerRuntime/DockerAPIClient.swift @@ -291,12 +291,27 @@ final class DockerAPIClient: Sendable { } /// Percent-encode a string for use as a URL path component. - /// Encodes everything except unreserved characters (RFC 3986 section 2.3). + /// + /// Allows unreserved characters (RFC 3986 §2.3) plus the sub-delimiter and + /// pchar extras that are legal in path segments — but NOT `/`, which must be + /// encoded when it appears inside a Docker image reference. static func pathEncodeComponent(_ value: String) -> String { - // .urlPathAllowed includes '/' which we must also encode for Docker image refs - var allowed = CharacterSet.urlPathAllowed - allowed.remove("/") - return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value + // Characters allowed in a URL path segment (RFC 3986) minus '/'. + let allowed: Set = Set( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:@!$&'()*+,;=" + ) + var result = "" + result.reserveCapacity(value.count) + for char in value { + if allowed.contains(char) { + result.append(char) + } else { + for byte in String(char).utf8 { + result += String(format: "%%%02X", byte) + } + } + } + return result } } diff --git a/Sources/AgentIsolationDockerRuntime/DockerRuntime.swift b/Sources/AgentIsolationDockerRuntime/DockerRuntime.swift index 389505d..9c75d6e 100644 --- a/Sources/AgentIsolationDockerRuntime/DockerRuntime.swift +++ b/Sources/AgentIsolationDockerRuntime/DockerRuntime.swift @@ -4,10 +4,25 @@ import Synchronization #if canImport(FoundationEssentials) import FoundationEssentials + import Dispatch #else import Foundation #endif +#if canImport(System) + import System +#else + import SystemPackage +#endif + +#if canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#elseif canImport(Musl) + import Musl +#endif + // MARK: - DockerRuntime /// Container runtime that communicates with Docker Engine via its HTTP API (v1.44). diff --git a/Sources/AgentIsolationDockerRuntime/DockerStreamAttach.swift b/Sources/AgentIsolationDockerRuntime/DockerStreamAttach.swift index f624e15..3fdaf42 100644 --- a/Sources/AgentIsolationDockerRuntime/DockerStreamAttach.swift +++ b/Sources/AgentIsolationDockerRuntime/DockerStreamAttach.swift @@ -3,10 +3,17 @@ import Synchronization #if canImport(FoundationEssentials) import FoundationEssentials + import Dispatch #else import Foundation #endif +#if canImport(System) + import System +#else + import SystemPackage +#endif + #if canImport(Darwin) import Darwin #elseif canImport(Glibc) @@ -79,8 +86,8 @@ final class DockerStreamAttach: Sendable { // MARK: - I/O - /// Start bidirectional I/O between the socket and file handles. - func startIO(stdin stdinFH: FileHandle, stdout stdoutFH: FileHandle, stderr stderrFH: FileHandle) + /// Start bidirectional I/O between the socket and file descriptors. + func startIO(stdin stdinFD: FileDescriptor, stdout stdoutFD: FileDescriptor, stderr stderrFD: FileDescriptor) { // Socket -> stdout/stderr let socketReadSource = DispatchSource.makeReadSource( @@ -95,9 +102,9 @@ final class DockerStreamAttach: Sendable { } let data = Data(buffer[0.. socket let stdinReadSource = DispatchSource.makeReadSource( - fileDescriptor: stdinFH.fileDescriptor, + fileDescriptor: stdinFD.rawValue, queue: DispatchQueue.global(qos: .userInteractive)) stdinReadSource.setEventHandler { [weak self] in guard let self = self else { return } var buffer = [UInt8](repeating: 0, count: 4096) - let bytesRead = read(stdinFH.fileDescriptor, &buffer, buffer.count) + let bytesRead = read(stdinFD.rawValue, &buffer, buffer.count) if bytesRead <= 0 { stdinReadSource.cancel() return @@ -174,8 +181,8 @@ final class DockerStreamAttach: Sendable { /// Start bidirectional I/O between the socket and custom Reader/Writer streams. /// /// Unlike ``startIO(stdin:stdout:stderr:)``, this method accepts protocol-based - /// streams instead of `FileHandle` objects, allowing callers to capture or - /// transform container output without file descriptors. + /// streams instead of `FileDescriptor` values, allowing callers to capture or + /// transform container output. func startCustomIO(stdin: any ReaderStream, stdout: any Writer, stderr: any Writer) { // Socket → Writers let socketReadSource = DispatchSource.makeReadSource( @@ -231,7 +238,7 @@ final class DockerStreamAttach: Sendable { /// /// Each frame has an 8-byte header: [type:1][padding:3][size:4_be] /// Type: 0=stdin, 1=stdout, 2=stderr - private func demuxWrite(_ data: Data, stdout: FileHandle, stderr: FileHandle) { + private func demuxWrite(_ data: Data, stdout: FileDescriptor, stderr: FileDescriptor) { state.withLock { state in state.demuxBuffer.append(data) @@ -246,9 +253,9 @@ final class DockerStreamAttach: Sendable { let payload = state.demuxBuffer[8..<(8 + size)] switch streamType { case 1: - stdout.write(Data(payload)) + Data(payload).withUnsafeBytes { _ = try? stdout.write($0) } case 2: - stderr.write(Data(payload)) + Data(payload).withUnsafeBytes { _ = try? stderr.write($0) } default: break } diff --git a/Sources/agentc/BootstrapManager.swift b/Sources/agentc/BootstrapManager.swift index 82633d4..bbe332f 100644 --- a/Sources/agentc/BootstrapManager.swift +++ b/Sources/agentc/BootstrapManager.swift @@ -4,6 +4,20 @@ import Foundation #endif +#if canImport(Glibc) + import Glibc +#elseif canImport(Musl) + import Musl +#endif + +#if canImport(System) + import System +#else + import SystemPackage +#endif + +import Subprocess + /// Locates or downloads the agentc-bootstrap binary used as the container entrypoint. enum BootstrapManager { /// Expected install location for the bootstrap binary. @@ -13,7 +27,7 @@ enum BootstrapManager { } /// Resolve the bootstrap binary path, downloading from GitHub Releases if missing. - static func resolveBootstrapBinary(verbose: Bool = false) throws -> URL { + static func resolveBootstrapBinary(verbose: Bool = false) async throws -> URL { let binaryPath = bootstrapBinaryPath if FileManager.default.fileExists(atPath: binaryPath.path) { @@ -32,24 +46,23 @@ enum BootstrapManager { """) } - try downloadBootstrap(version: BuildInfo.version, to: binaryPath, verbose: verbose) + try await downloadBootstrap(version: BuildInfo.version, to: binaryPath, verbose: verbose) return binaryPath } private static func downloadBootstrap( version: String, to destination: URL, verbose: Bool - ) throws { + ) async throws { let arch = hostArchLabel() let assetName = "agentc-bootstrap-\(arch)-linux-static.tar.gz" let url = "https://github.com/laosb/agentc/releases/download/v\(version)/\(assetName)" if verbose { - FileHandle.standardError.write( - Data("agentc: downloading bootstrap binary...\n".utf8)) + writeToStderr("agentc: downloading bootstrap binary...\n") } - let tmpDir = URL(fileURLWithPath: NSTemporaryDirectory()) + let tmpDir = FileManager.default.temporaryDirectory .appendingPathComponent("agentc-bootstrap-dl-\(UUID().uuidString)") try FileManager.default.createDirectory( at: tmpDir, withIntermediateDirectories: true) @@ -58,23 +71,23 @@ enum BootstrapManager { let tarPath = tmpDir.appendingPathComponent(assetName) // Download - let curl = Process() - curl.executableURL = URL(fileURLWithPath: "/usr/bin/env") - curl.arguments = ["curl", "-fsSL", url, "-o", tarPath.path] - try curl.run() - curl.waitUntilExit() - guard curl.terminationStatus == 0 else { + let curlResult = try await run( + .name("curl"), + arguments: ["-fsSL", url, "-o", tarPath.path], + output: .discarded + ) + guard curlResult.terminationStatus.isSuccess else { throw AgentcError.bootstrapDownloadFailed( "Failed to download bootstrap binary from \(url)") } // Extract - let tar = Process() - tar.executableURL = URL(fileURLWithPath: "/usr/bin/env") - tar.arguments = ["tar", "xzf", tarPath.path, "-C", tmpDir.path] - try tar.run() - tar.waitUntilExit() - guard tar.terminationStatus == 0 else { + let tarResult = try await run( + .name("tar"), + arguments: ["xzf", tarPath.path, "-C", tmpDir.path], + output: .discarded + ) + guard tarResult.terminationStatus.isSuccess else { throw AgentcError.bootstrapDownloadFailed( "Failed to extract bootstrap archive") } @@ -90,9 +103,7 @@ enum BootstrapManager { [.posixPermissions: 0o755], ofItemAtPath: destination.path) if verbose { - FileHandle.standardError.write( - Data( - "agentc: bootstrap binary installed to \(destination.path)\n".utf8)) + writeToStderr("agentc: bootstrap binary installed to \(destination.path)\n") } } diff --git a/Sources/agentc/Commands/RunCommand.swift b/Sources/agentc/Commands/RunCommand.swift index c85b97b..5ec1be5 100644 --- a/Sources/agentc/Commands/RunCommand.swift +++ b/Sources/agentc/Commands/RunCommand.swift @@ -6,6 +6,14 @@ import ArgumentParser import Foundation #endif +#if canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#elseif canImport(Musl) + import Musl +#endif + struct RunCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "run", diff --git a/Sources/agentc/Commands/ShellCommand.swift b/Sources/agentc/Commands/ShellCommand.swift index f7aa543..4f23549 100644 --- a/Sources/agentc/Commands/ShellCommand.swift +++ b/Sources/agentc/Commands/ShellCommand.swift @@ -6,6 +6,14 @@ import ArgumentParser import Foundation #endif +#if canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#elseif canImport(Musl) + import Musl +#endif + struct ShellCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "sh", diff --git a/Sources/agentc/ConfigurationsManager.swift b/Sources/agentc/ConfigurationsManager.swift index 76e43e1..f66a0f3 100644 --- a/Sources/agentc/ConfigurationsManager.swift +++ b/Sources/agentc/ConfigurationsManager.swift @@ -4,6 +4,20 @@ import Foundation #endif +#if canImport(Glibc) + import Glibc +#elseif canImport(Musl) + import Musl +#endif + +#if canImport(System) + import System +#else + import SystemPackage +#endif + +import Subprocess + /// Manages the agent-isolation-configurations git repository (clone / pull). enum ConfigurationsManager { static let defaultRepo = "https://github.com/laosb/agent-isolation-configurations" @@ -17,7 +31,7 @@ enum ConfigurationsManager { at dir: URL, repoURL: String? = nil, updateInterval: Int? = nil - ) throws { + ) async throws { let repo = repoURL ?? defaultRepo let interval = updateInterval ?? defaultUpdateInterval @@ -45,14 +59,13 @@ enum ConfigurationsManager { if FileManager.default.fileExists(atPath: dir.path) { try FileManager.default.removeItem(at: dir) } - FileHandle.standardError.write( - Data("agentc: cloning configurations repo...\n".utf8)) - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/git") - process.arguments = ["clone", "--depth", "1", repo, dir.path] - try process.run() - process.waitUntilExit() - guard process.terminationStatus == 0 else { + writeToStderr("agentc: cloning configurations repo...\n") + let result = try await run( + .path("/usr/bin/git"), + arguments: ["clone", "--depth", "1", repo, dir.path], + output: .discarded + ) + guard result.terminationStatus.isSuccess else { throw AgentcError.configRepoError( "Failed to clone configurations repo from \(repo)") } @@ -70,13 +83,12 @@ enum ConfigurationsManager { } // Pull updates - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/git") - process.arguments = ["-C", dir.path, "pull", "--ff-only", "--quiet"] - process.standardOutput = FileHandle.nullDevice - process.standardError = FileHandle.nullDevice - try process.run() - process.waitUntilExit() + _ = try? await run( + .path("/usr/bin/git"), + arguments: ["-C", dir.path, "pull", "--ff-only", "--quiet"], + output: .discarded, + error: .discarded + ) // Update marker regardless of pull success (avoid repeated failures) FileManager.default.createFile(atPath: markerFile.path, contents: nil) } diff --git a/Sources/agentc/MigrationCheck.swift b/Sources/agentc/MigrationCheck.swift index 6dfb1ba..bde1e7b 100644 --- a/Sources/agentc/MigrationCheck.swift +++ b/Sources/agentc/MigrationCheck.swift @@ -6,6 +6,12 @@ import ArgumentParser import Foundation #endif +#if canImport(Glibc) + import Glibc +#elseif canImport(Musl) + import Musl +#endif + /// Checks whether the user should migrate from the legacy ~/.claudec directory. /// /// Returns `true` if migration is needed and the user should be prompted. @@ -20,7 +26,7 @@ enum MigrationCheck { if let home = ProcessInfo.processInfo.environment["HOME"], !home.isEmpty { return URL(fileURLWithPath: home) } - return URL(fileURLWithPath: NSHomeDirectory()) + return FileManager.default.homeDirectoryForCurrentUser } /// The agentc data directory: ~/.agentc @@ -53,12 +59,13 @@ enum MigrationCheck { guard fm.fileExists(atPath: claudecDir.path) else { return } // Migration needed - FileHandle.standardError.write(Data(""" + let message = """ agentc: Found existing ~/.claudec directory from the legacy claudec CLI. Run `agentc migrate-from-claudec` to migrate your profiles and configurations. Or use `--suppress-migration-from-claudec` to skip this check. - """.utf8)) + """ + writeToStderr(message) throw ExitCode(1) } } diff --git a/Sources/agentc/SessionRunner.swift b/Sources/agentc/SessionRunner.swift index f9ba962..5b292bf 100644 --- a/Sources/agentc/SessionRunner.swift +++ b/Sources/agentc/SessionRunner.swift @@ -48,13 +48,14 @@ enum SessionRunner { let excludeFolders = options.resolveExcludeFolders(projectSettings: projectSettings) // Ensure configurations repo - try ConfigurationsManager.ensureRepo( + try await ConfigurationsManager.ensureRepo( at: configurationsDir, repoURL: options.configurationsRepo, updateInterval: options.configurationsUpdateInterval ) let resolvedImage = options.resolveImage(projectSettings: projectSettings) + let bootstrapMode = try await options.resolveBootstrapMode(projectSettings: projectSettings) let isolationConfig = IsolationConfig( image: resolvedImage, @@ -63,7 +64,7 @@ enum SessionRunner { excludeFolders: excludeFolders, configurationsDir: configurationsDir, configurations: configNames, - bootstrapMode: try options.resolveBootstrapMode(projectSettings: projectSettings), + bootstrapMode: bootstrapMode, arguments: arguments, allocateTTY: allocateTTY, cpuCount: options.resolveCpuCount(projectSettings: projectSettings), diff --git a/Sources/agentc/SharedOptions.swift b/Sources/agentc/SharedOptions.swift index 15b542e..4c9e5e7 100644 --- a/Sources/agentc/SharedOptions.swift +++ b/Sources/agentc/SharedOptions.swift @@ -182,7 +182,7 @@ extension SharedOptions { /// Resolve the bootstrap mode from CLI flags, project settings, and installed binary. /// /// Priority: CLI --respect-image-entrypoint → CLI --bootstrap → project settings → installed binary. - func resolveBootstrapMode(projectSettings: ProjectSettings? = nil) throws -> BootstrapMode { + func resolveBootstrapMode(projectSettings: ProjectSettings? = nil) async throws -> BootstrapMode { if respectImageEntrypoint { return .imageDefault } @@ -197,7 +197,7 @@ extension SharedOptions { return .file(URL(fileURLWithPath: path)) } } - let binary = try BootstrapManager.resolveBootstrapBinary(verbose: verbose) + let binary = try await BootstrapManager.resolveBootstrapBinary(verbose: verbose) return .file(binary) } diff --git a/Sources/agentc/StderrWriter.swift b/Sources/agentc/StderrWriter.swift new file mode 100644 index 0000000..1a57dfd --- /dev/null +++ b/Sources/agentc/StderrWriter.swift @@ -0,0 +1,16 @@ +#if canImport(Glibc) + import Glibc +#elseif canImport(Musl) + import Musl +#elseif canImport(Darwin) + import Darwin +#endif + +/// Write a message to standard error. Concurrency-safe (uses the STDERR_FILENO +/// constant and the POSIX `write` syscall rather than C's mutable `stderr` global). +func writeToStderr(_ message: String) { + var msg = message + msg.withUTF8 { buf in + _ = write(STDERR_FILENO, buf.baseAddress!, buf.count) + } +} diff --git a/Tests/AgentcIntegrationTests/AgentcHelpers.swift b/Tests/AgentcIntegrationTests/AgentcHelpers.swift index 63aa918..ce506bb 100644 --- a/Tests/AgentcIntegrationTests/AgentcHelpers.swift +++ b/Tests/AgentcIntegrationTests/AgentcHelpers.swift @@ -1,5 +1,12 @@ import Crypto import Foundation +import Subprocess + +#if canImport(System) + import System +#else + import SystemPackage +#endif // MARK: - Process Helper @@ -21,45 +28,32 @@ func runAgentc( .deletingLastPathComponent() // Tests/ let agentcPath = repoRoot.appendingPathComponent("agentc").path - var environment = ProcessInfo.processInfo.environment - // Clean environment for agentc tests - environment.removeValue(forKey: "AGENTC_CONFIGURATIONS") - environment.removeValue(forKey: "AGENTC_ENTRYPOINT_OVERRIDE") - + // Build environment overrides: remove agentc internal vars, add test-specific ones + var overrides: [Environment.Key: String?] = [ + "AGENTC_CONFIGURATIONS": nil, + "AGENTC_ENTRYPOINT_OVERRIDE": nil, + ] for (key, value) in env { - environment[key] = value + overrides[Environment.Key(stringLiteral: key)] = value } - return await withCheckedContinuation { continuation in - let process = Process() - process.executableURL = URL(fileURLWithPath: agentcPath) - process.arguments = args - process.environment = environment - - if let cwd { - process.currentDirectoryURL = URL(fileURLWithPath: cwd) - } - - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - - process.terminationHandler = { p in - let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() - let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() - let stdout = String(data: stdoutData, encoding: .utf8) ?? "" - let stderr = String(data: stderrData, encoding: .utf8) ?? "" - continuation.resume( - returning: ProcessOutput(exitCode: p.terminationStatus, stdout: stdout, stderr: stderr)) - } - - do { - try process.run() - } catch { - continuation.resume( - returning: ProcessOutput(exitCode: -1, stdout: "", stderr: "launch error: \(error)")) + do { + let result = try await run( + .path(FilePath(agentcPath)), + arguments: Arguments(args), + environment: .inherit.updating(overrides), + workingDirectory: cwd.map { FilePath($0) }, + output: .string(limit: 512 * 1024), + error: .string(limit: 512 * 1024) + ) + let exitCode: Int32 + switch result.terminationStatus { + case .exited(let code): exitCode = code + case .signaled(let sig): exitCode = sig } + return ProcessOutput(exitCode: exitCode, stdout: result.standardOutput ?? "", stderr: result.standardError ?? "") + } catch { + return ProcessOutput(exitCode: -1, stdout: "", stderr: "launch error: \(error)") } } @@ -132,22 +126,10 @@ let sharedConfigurationsDir: String = { try? FileManager.default.setAttributes( [.posixPermissions: 0o755], ofItemAtPath: prepareScript.path) - // Make it a valid git repo so ConfigurationsManager.ensureRepo skips cloning. + // Create a .git directory so ConfigurationsManager.ensureRepo skips cloning. let gitDir = dir.appendingPathComponent(".git") if !FileManager.default.fileExists(atPath: gitDir.path) { - for args: [String] in [ - ["init"], - ["add", "."], - ["-c", "user.email=test@test.com", "-c", "user.name=Test", "commit", "-m", "init"], - ] { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/git") - process.arguments = ["-C", dir.path] + args - process.standardOutput = FileHandle.nullDevice - process.standardError = FileHandle.nullDevice - try? process.run() - process.waitUntilExit() - } + try? FileManager.default.createDirectory(at: gitDir, withIntermediateDirectories: true) } // Touch the pull-marker so ensureRepo doesn't try to pull (there's no remote). @@ -159,7 +141,7 @@ let sharedConfigurationsDir: String = { // MARK: - Local Config Repo -func createLocalConfigRepo(at repoDir: URL) throws { +func createLocalConfigRepo(at repoDir: URL) async throws { let fm = FileManager.default let claudeDir = repoDir.appendingPathComponent("claude") try fm.createDirectory(at: claudeDir, withIntermediateDirectories: true) @@ -178,14 +160,13 @@ func createLocalConfigRepo(at repoDir: URL) throws { ["add", "."], ["-c", "user.email=test@test.com", "-c", "user.name=Test", "commit", "-m", "init"], ] { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/git") - process.arguments = ["-C", repoDir.path] + args - process.standardOutput = FileHandle.nullDevice - process.standardError = FileHandle.nullDevice - try process.run() - process.waitUntilExit() - guard process.terminationStatus == 0 else { + let result = try await run( + .path("/usr/bin/git"), + arguments: Arguments(["-C", repoDir.path] + args), + output: .discarded, + error: .discarded + ) + guard result.terminationStatus.isSuccess else { fatalError("git \(args.joined(separator: " ")) failed in createLocalConfigRepo") } } diff --git a/Tests/AgentcIntegrationTests/AgentcIntegrationTests.swift b/Tests/AgentcIntegrationTests/AgentcIntegrationTests.swift index 531184a..244cda9 100644 --- a/Tests/AgentcIntegrationTests/AgentcIntegrationTests.swift +++ b/Tests/AgentcIntegrationTests/AgentcIntegrationTests.swift @@ -249,7 +249,7 @@ struct AgentcIntegrationTests { let configsDir = tempDir.appendingPathComponent("configurations") let localRepo = tempDir.appendingPathComponent("repo") - try createLocalConfigRepo(at: localRepo) + try await createLocalConfigRepo(at: localRepo) // `agentc run --configurations claude` with custom configs dir/repo let result = await runAgentc( @@ -356,7 +356,7 @@ struct AgentcIntegrationTests { let configsDir = tempDir.appendingPathComponent("configurations") let localRepo = tempDir.appendingPathComponent("repo") - try createLocalConfigRepo(at: localRepo) + try await createLocalConfigRepo(at: localRepo) let result = await runAgentc( args: [ @@ -398,7 +398,7 @@ struct AgentcIntegrationTests { let configsDir = tempDir.appendingPathComponent("configurations") let localRepo = tempDir.appendingPathComponent("repo") - try createLocalConfigRepo(at: localRepo) + try await createLocalConfigRepo(at: localRepo) let result = await runAgentc( args: [