diff --git a/README.md b/README.md index 55ce107..b502893 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,6 @@ Run AI coding agents in isolated containers with persistent profiles and per-pro Supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [GitHub Copilot CLI](https://gh.io/copilot-install), and more — with pluggable agent configurations via the [agent-isolation-configurations](https://github.com/laosb/agent-isolation-configurations) repo. Contributions for additional agents are welcome! -Available container runtimes: [Apple Containerization](https://apple.github.io/containerization/) (macOS) and Docker (macOS/Linux). - ## Install ### Prerequisites @@ -20,8 +18,6 @@ Available container runtimes: [Apple Containerization](https://apple.github.io/c curl -fsSL https://raw.githubusercontent.com/laosb/agentc/main/install.sh | sh ``` -This detects your platform, downloads the latest release from GitHub, and installs to `~/.agentc/bin/` with a symlink in `~/.local/bin/`. - ## Quick Start ```sh @@ -33,24 +29,22 @@ agentc sh -- ls -la /home/agent # run a command inside the container agentc version # print version info ``` -On first run, `agentc` clones the [agent-isolation-configurations](https://github.com/laosb/agent-isolation-configurations) repo and runs each configuration's `prepare.sh` to install required tools. Subsequent runs reuse the existing install. - Use `agentc --help` and `agentc --help` for full CLI reference. ### Profiles -A profile is a persistent `/home/agent` directory that survives container restarts — keeping agent auth, memory, settings, and MCP servers. +A profile contains a persistent `/home/agent` directory that survives container restarts — keeping agent auth, memory, settings, and MCP servers. ```sh agentc run -p work # use a named profile agentc run --profile-dir ~/my-prof # use a custom directory ``` -Profiles are stored at `~/.agentc/profiles//home/`. +Profiles are stored at `~/.agentc/profiles//`. ### Configurations -Agent configurations are modular setup recipes. Each configuration provides a `prepare.sh` script, optional additional PATH entries, and an entrypoint command. The last configuration's entrypoint is used. +Agent configurations are modular setup recipes. Each configuration provides a `prepare.sh` script and optional additional settings. The last configuration's entrypoint is used. ```sh # makes sure both Claude Code + GitHub Copilot CLI installed, but invokes GitHub Copilot CLI @@ -60,6 +54,26 @@ agentc run -c claude,copilot 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. + +See [docs/project-settings.md](./docs/project-settings.md) for the full schema and override rules. + ### Container Images `agentc` works with any standard container image — it automatically sets up the agent user, sudo, and required tools at container start via an embedded bootstrap script. The default image is pre-configured for faster startup, but you can use any base image: @@ -84,11 +98,12 @@ agentc (CLI) └─ AgentIsolation (runtime-agnostic orchestration) └─ AgentIsolationAppleContainerRuntime (Apple Containerization, macOS) └─ AgentIsolationDockerRuntime (Docker Engine API, macOS/Linux) +agentc-bootstrap (In-container bootstrap program) ``` `AgentIsolation` depends only on Foundation and [swift-crypto](https://github.com/apple/swift-crypto). Runtime backends are conditionally compiled via Swift package traits. -The `agentc-bootstrap` binary is a standalone statically linked Linux executable that runs as the container entrypoint. It creates `agent` user and does the rest of agent initialization as needed. It is distributed as a separate release artifact and installed alongside the `agentc` binary. +The `agentc-bootstrap` binary is a standalone statically-linked Linux executable that runs as the container entrypoint. It creates `agent` user and does the rest of agent initialization as needed. ## Development @@ -131,11 +146,7 @@ agentc migrate-from-claudec If you have scripts or muscle memory that use the `claudec` command, you can set up a shell alias: ```sh -# bash / zsh alias claudec='agentc run --' - -# fish -alias claudec 'agentc run --' ``` ## License diff --git a/Sources/AgentIsolation/ProjectSettings.swift b/Sources/AgentIsolation/ProjectSettings.swift new file mode 100644 index 0000000..18825ab --- /dev/null +++ b/Sources/AgentIsolation/ProjectSettings.swift @@ -0,0 +1,65 @@ +import Foundation + +/// Project-level settings loaded from `.agentc/settings.json`. +/// +/// 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 var agent: AgentSettings? + + public struct AgentSettings: Decodable, Sendable, Equatable { + public var image: String? + public var profile: String? + public var excludes: [String]? + public var configurations: [String]? + public var additionalMounts: [String]? + public var defaultArguments: [String]? + public var additionalArguments: [String]? + public var cpus: Int? + public var memoryMiB: Int? + public var bootstrap: String? + public var respectImageEntrypoint: Bool? + } + + /// The folder names to probe at each directory level, in priority order. + private static let folderNames = [".boite", ".agentc"] + + /// Searches upward from `startDir` for a settings file. + /// + /// At each directory level, checks for `settings.json` inside the candidate + /// folders (in priority order). Returns the first successfully decoded + /// settings, or `nil` if the filesystem root is reached without a match. + public static func find(from startDir: URL) -> ProjectSettings? { + var dir = startDir.standardizedFileURL + while true { + for folderName in folderNames { + let settingsURL = dir + .appendingPathComponent(folderName) + .appendingPathComponent("settings.json") + if let settings = load(from: settingsURL) { + return settings + } + } + let parent = dir.deletingLastPathComponent() + if parent.path == dir.path { break } + dir = parent + } + return nil + } + + /// Loads settings from a specific folder (e.g. the path given via `--agentc-folder`). + public static func load(fromFolder folder: URL) -> ProjectSettings? { + load(from: folder.appendingPathComponent("settings.json")) + } + + /// Loads and decodes a settings file at the given URL. + private static func load(from url: URL) -> ProjectSettings? { + guard let data = try? Data(contentsOf: url), + let settings = try? JSONDecoder().decode(ProjectSettings.self, from: data) + else { + return nil + } + return settings + } +} diff --git a/Sources/agentc/RunCommand.swift b/Sources/agentc/RunCommand.swift index e7a3ddc..e4135b9 100644 --- a/Sources/agentc/RunCommand.swift +++ b/Sources/agentc/RunCommand.swift @@ -35,13 +35,16 @@ struct RunCommand: AsyncParsableCommand { // Check for legacy claudec data before proceeding try MigrationCheck.checkIfNeeded(suppress: options.suppressMigrationFromClaudec) - let (_, profileDir) = options.resolveProfile() + 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: options.configurationsFlag, profileDir: profileDir) - let excludeFolders = options.resolveExcludeFolders() + positional: options.configurationsFlag, profileDir: profileDir, + projectSettings: projectSettings) + let excludeFolders = options.resolveExcludeFolders(projectSettings: projectSettings) let allocateTTY = isatty(STDIN_FILENO) == 1 && isatty(STDOUT_FILENO) == 1 // Ensure configurations repo @@ -51,19 +54,23 @@ struct RunCommand: AsyncParsableCommand { updateInterval: options.configurationsUpdateInterval ) + let resolvedImage = options.resolveImage(projectSettings: projectSettings) + let resolvedArguments = options.resolveArguments( + entrypointArguments: entrypointArguments, projectSettings: projectSettings) + let isolationConfig = IsolationConfig( - image: options.image, + image: resolvedImage, profileHomeDir: profileHomeDir, workspace: workspace, excludeFolders: excludeFolders, configurationsDir: configurationsDir, configurations: configNames, - bootstrapMode: try options.resolveBootstrapMode(), - arguments: entrypointArguments, + bootstrapMode: try options.resolveBootstrapMode(projectSettings: projectSettings), + arguments: resolvedArguments, allocateTTY: allocateTTY, - cpuCount: options.cpuCount, - memoryLimitMiB: options.memoryLimitMiB, - additionalHostMounts: options.additionalMount.map { URL(fileURLWithPath: $0) }, + cpuCount: options.resolveCpuCount(projectSettings: projectSettings), + memoryLimitMiB: options.resolveMemoryLimitMiB(projectSettings: projectSettings), + additionalHostMounts: options.resolveAdditionalMounts(projectSettings: projectSettings), verbose: options.verbose ) diff --git a/Sources/agentc/SharedOptions.swift b/Sources/agentc/SharedOptions.swift index 81efe3b..d48bbea 100644 --- a/Sources/agentc/SharedOptions.swift +++ b/Sources/agentc/SharedOptions.swift @@ -12,8 +12,10 @@ struct SharedOptions: ParsableArguments { @Option(name: .long, help: "Custom profile directory path (overrides --profile).") var profileDir: String? - @Option(name: .shortAndLong, help: "Container image reference.") - var image: String = "ghcr.io/laosb/claudec:latest" + @Option( + name: .shortAndLong, + help: "Container image reference (default: ghcr.io/laosb/claudec:latest).") + var image: String? @Flag( name: .long, @@ -66,31 +68,49 @@ struct SharedOptions: ParsableArguments { @Option(name: .long, help: "Docker Engine API endpoint (socket path or tcp://host:port).") var dockerEndpoint: String? - @Option(name: .customLong("cpus"), help: "Number of CPUs to allocate to the container.") - var cpuCount: Int = 1 + @Option( + name: .customLong("cpus"), + help: "Number of CPUs to allocate to the container (default: 1).") + var cpuCount: Int? @Option( name: .customLong("memory-mib"), - help: "Container memory limit in MiB (e.g. 1536 = 1.5 GiB).") - var memoryLimitMiB: Int = 1536 + help: "Container memory limit in MiB (default: 1536).") + var memoryLimitMiB: Int? @Flag(name: .long, help: "Skip the migration check for legacy ~/.claudec data.") var suppressMigrationFromClaudec: Bool = false @Flag(name: .shortAndLong, help: "Print extra information (image pulls, bootstrap setup, etc.).") var verbose: Bool = false + + @Option(name: .customLong("agentc-folder"), help: "Custom project settings folder path.") + var agentcFolder: String? +} + +// MARK: - Project Settings + +extension SharedOptions { + /// Load project settings from `--agentc-folder` or by searching upward from CWD. + func loadProjectSettings() -> ProjectSettings? { + if let folder = agentcFolder, !folder.isEmpty { + return ProjectSettings.load(fromFolder: URL(fileURLWithPath: folder)) + } + let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + return ProjectSettings.find(from: cwd) + } } // MARK: - Resolution helpers extension SharedOptions { /// Resolve profile directory and name from options. - func resolveProfile() -> (name: String, dir: URL) { + func resolveProfile(projectSettings: ProjectSettings? = nil) -> (name: String, dir: URL) { if let dir = profileDir, !dir.isEmpty { let url = URL(fileURLWithPath: dir) return (url.lastPathComponent, url) } - let name = profile ?? "default" + let name = profile ?? projectSettings?.agent?.profile ?? "default" let home = MigrationCheck.homeDir return ( name, @@ -116,20 +136,33 @@ extension SharedOptions { } /// Resolve excluded folders list. - func resolveExcludeFolders() -> [String] { - guard let raw = excludeFolders, !raw.isEmpty else { return [] } - return raw.split(separator: ",").map(String.init) + /// When both CLI and project settings specify excludes, both sets are merged. + func resolveExcludeFolders(projectSettings: ProjectSettings? = nil) -> [String] { + var result = [String]() + if let raw = excludeFolders, !raw.isEmpty { + result.append(contentsOf: raw.split(separator: ",").map(String.init)) + } + if let extras = projectSettings?.agent?.excludes { + result.append(contentsOf: extras) + } + return result } /// Resolve the ordered list of configuration names. /// - /// Priority: explicit flag/positional → profile settings.json → `["claude"]` default. - func resolveConfigurations(positional: String?, profileDir: URL) -> [String] { + /// Priority: explicit flag → project settings → profile settings.json → `["claude"]` default. + func resolveConfigurations( + positional: String?, profileDir: URL, projectSettings: ProjectSettings? = nil + ) -> [String] { // 1. Explicit (flag or positional) if let explicit = configurationsFlag ?? positional, !explicit.isEmpty { return explicit.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } } - // 2. Profile settings.json + // 2. Project settings + if let configs = projectSettings?.agent?.configurations, !configs.isEmpty { + return configs + } + // 3. Profile settings.json let settingsURL = profileDir.appendingPathComponent("settings.json") if let data = try? Data(contentsOf: settingsURL), let settings = try? JSONDecoder().decode(ProfileSettings.self, from: data), @@ -137,23 +170,75 @@ extension SharedOptions { { return configs } - // 3. Default + // 4. Default return ["claude"] } - /// Resolve the bootstrap mode from CLI flags and installed binary. + /// Resolve the bootstrap mode from CLI flags, project settings, and installed binary. /// - /// Priority: --respect-image-entrypoint → --bootstrap → installed binary (auto-download). - func resolveBootstrapMode() throws -> BootstrapMode { + /// Priority: CLI --respect-image-entrypoint → CLI --bootstrap → project settings → installed binary. + func resolveBootstrapMode(projectSettings: ProjectSettings? = nil) throws -> BootstrapMode { if respectImageEntrypoint { return .imageDefault } if let path = bootstrapScript, !path.isEmpty { return .file(URL(fileURLWithPath: path)) } + if let agent = projectSettings?.agent { + if agent.respectImageEntrypoint == true { + return .imageDefault + } + if let path = agent.bootstrap, !path.isEmpty { + return .file(URL(fileURLWithPath: path)) + } + } let binary = try BootstrapManager.resolveBootstrapBinary(verbose: verbose) return .file(binary) } + + /// Resolve image reference. CLI flag → project settings → default. + func resolveImage(projectSettings: ProjectSettings? = nil) -> String { + image ?? projectSettings?.agent?.image ?? "ghcr.io/laosb/claudec:latest" + } + + /// Resolve CPU count. CLI flag → project settings → 1. + func resolveCpuCount(projectSettings: ProjectSettings? = nil) -> Int { + cpuCount ?? projectSettings?.agent?.cpus ?? 1 + } + + /// Resolve memory limit. CLI flag → project settings → 1536. + func resolveMemoryLimitMiB(projectSettings: ProjectSettings? = nil) -> Int { + memoryLimitMiB ?? projectSettings?.agent?.memoryMiB ?? 1536 + } + + /// Resolve additional host mounts. + /// When both CLI flags and project settings specify mounts, both sets are mounted. + func resolveAdditionalMounts(projectSettings: ProjectSettings? = nil) -> [URL] { + var result = additionalMount.map { URL(fileURLWithPath: $0) } + if let extras = projectSettings?.agent?.additionalMounts { + result.append(contentsOf: extras.map { URL(fileURLWithPath: $0) }) + } + return result + } + + /// Resolve entrypoint arguments. + /// + /// - `defaultArguments`: used when no CLI rest arguments are given; CLI overrides. + /// - `additionalArguments`: always appended regardless. + func resolveArguments( + entrypointArguments: [String], projectSettings: ProjectSettings? = nil + ) -> [String] { + var args: [String] + if entrypointArguments.isEmpty { + args = projectSettings?.agent?.defaultArguments ?? [] + } else { + args = entrypointArguments + } + if let additional = projectSettings?.agent?.additionalArguments { + args.append(contentsOf: additional) + } + return args + } } /// Settings from a profile's settings.json. diff --git a/Sources/agentc/ShellCommand.swift b/Sources/agentc/ShellCommand.swift index be6b6b6..9162c10 100644 --- a/Sources/agentc/ShellCommand.swift +++ b/Sources/agentc/ShellCommand.swift @@ -34,13 +34,15 @@ struct ShellCommand: AsyncParsableCommand { // Check for legacy claudec data before proceeding try MigrationCheck.checkIfNeeded(suppress: options.suppressMigrationFromClaudec) - let (_, profileDir) = options.resolveProfile() + 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) - let excludeFolders = options.resolveExcludeFolders() + positional: nil, profileDir: profileDir, projectSettings: projectSettings) + let excludeFolders = options.resolveExcludeFolders(projectSettings: projectSettings) // sh is interactive when no command is given let allocateTTY: Bool @@ -65,19 +67,21 @@ struct ShellCommand: AsyncParsableCommand { entrypointOverride = ["/bin/bash", "-c", command.joined(separator: " ")] } + let resolvedImage = options.resolveImage(projectSettings: projectSettings) + let isolationConfig = IsolationConfig( - image: options.image, + image: resolvedImage, profileHomeDir: profileHomeDir, workspace: workspace, excludeFolders: excludeFolders, configurationsDir: configurationsDir, configurations: configNames, - bootstrapMode: try options.resolveBootstrapMode(), + bootstrapMode: try options.resolveBootstrapMode(projectSettings: projectSettings), arguments: [], allocateTTY: allocateTTY, - cpuCount: options.cpuCount, - memoryLimitMiB: options.memoryLimitMiB, - additionalHostMounts: options.additionalMount.map { URL(fileURLWithPath: $0) }, + cpuCount: options.resolveCpuCount(projectSettings: projectSettings), + memoryLimitMiB: options.resolveMemoryLimitMiB(projectSettings: projectSettings), + additionalHostMounts: options.resolveAdditionalMounts(projectSettings: projectSettings), verbose: options.verbose ) diff --git a/Tests/AgentIsolationTests/ProjectSettingsTests.swift b/Tests/AgentIsolationTests/ProjectSettingsTests.swift new file mode 100644 index 0000000..314d682 --- /dev/null +++ b/Tests/AgentIsolationTests/ProjectSettingsTests.swift @@ -0,0 +1,312 @@ +import AgentIsolation +import Foundation +import Testing + +// MARK: - Decoding Tests + +@Suite("ProjectSettings Decoding") +struct ProjectSettingsDecodingTests { + + @Test("Decodes all agent fields") + func decodesAllFields() throws { + let json = """ + { + "agent": { + "image": "my-image:latest", + "profile": "work", + "excludes": [".git", "node_modules"], + "configurations": ["claude", "copilot"], + "additionalMounts": ["/data/models"], + "defaultArguments": ["--model", "opus"], + "additionalArguments": ["--verbose"], + "cpus": 4, + "memoryMiB": 2048, + "bootstrap": "/usr/local/bin/my-bootstrap", + "respectImageEntrypoint": true + } + } + """ + let settings = try JSONDecoder().decode( + ProjectSettings.self, from: Data(json.utf8)) + + let agent = try #require(settings.agent) + #expect(agent.image == "my-image:latest") + #expect(agent.profile == "work") + #expect(agent.excludes == [".git", "node_modules"]) + #expect(agent.configurations == ["claude", "copilot"]) + #expect(agent.additionalMounts == ["/data/models"]) + #expect(agent.defaultArguments == ["--model", "opus"]) + #expect(agent.additionalArguments == ["--verbose"]) + #expect(agent.cpus == 4) + #expect(agent.memoryMiB == 2048) + #expect(agent.bootstrap == "/usr/local/bin/my-bootstrap") + #expect(agent.respectImageEntrypoint == true) + } + + @Test("Decodes empty object") + func decodesEmpty() throws { + let json = "{}" + let settings = try JSONDecoder().decode( + ProjectSettings.self, from: Data(json.utf8)) + #expect(settings.agent == nil) + } + + @Test("Decodes partial agent fields") + func decodesPartial() throws { + let json = """ + { + "agent": { + "image": "custom:v1", + "cpus": 2 + } + } + """ + let settings = try JSONDecoder().decode( + ProjectSettings.self, from: Data(json.utf8)) + + let agent = try #require(settings.agent) + #expect(agent.image == "custom:v1") + #expect(agent.cpus == 2) + #expect(agent.profile == nil) + #expect(agent.excludes == nil) + #expect(agent.configurations == nil) + #expect(agent.additionalMounts == nil) + #expect(agent.defaultArguments == nil) + #expect(agent.additionalArguments == nil) + #expect(agent.memoryMiB == nil) + #expect(agent.bootstrap == nil) + #expect(agent.respectImageEntrypoint == nil) + } + + @Test("Decodes agent with empty object") + func decodesEmptyAgent() throws { + let json = """ + { "agent": {} } + """ + let settings = try JSONDecoder().decode( + ProjectSettings.self, from: Data(json.utf8)) + + let agent = try #require(settings.agent) + #expect(agent.image == nil) + #expect(agent.cpus == nil) + } +} + +// MARK: - File Search Tests + +@Suite("ProjectSettings Search") +struct ProjectSettingsSearchTests { + + /// Helper to create a temporary directory tree with a settings file. + private func makeTempDir() -> URL { + URL(fileURLWithPath: "/tmp/agentc-test-ps-\(UUID().uuidString)") + } + + private func writeSettings(_ json: String, at folder: URL, folderName: String) throws { + let dir = folder.appendingPathComponent(folderName) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try json.write( + to: dir.appendingPathComponent("settings.json"), + atomically: true, + encoding: .utf8 + ) + } + + @Test("Finds settings in current directory") + func findsInCurrentDir() throws { + let base = makeTempDir() + defer { try? FileManager.default.removeItem(at: base) } + + try writeSettings( + """ + { "agent": { "image": "found:here" } } + """, + at: base, folderName: ".agentc") + + let settings = ProjectSettings.find(from: base) + #expect(settings?.agent?.image == "found:here") + } + + @Test("Finds settings in parent directory") + func findsInParentDir() throws { + let base = makeTempDir() + let child = base.appendingPathComponent("subdir") + try FileManager.default.createDirectory(at: child, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + try writeSettings( + """ + { "agent": { "image": "found:parent" } } + """, + at: base, folderName: ".agentc") + + let settings = ProjectSettings.find(from: child) + #expect(settings?.agent?.image == "found:parent") + } + + @Test("Finds settings in grandparent directory") + func findsInGrandparentDir() throws { + let base = makeTempDir() + let grandchild = base.appendingPathComponent("a/b") + try FileManager.default.createDirectory(at: grandchild, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + try writeSettings( + """ + { "agent": { "cpus": 8 } } + """, + at: base, folderName: ".agentc") + + let settings = ProjectSettings.find(from: grandchild) + #expect(settings?.agent?.cpus == 8) + } + + @Test("Prefers .boite over .agentc in same directory") + func prefersBoiteOverAgentc() throws { + let base = makeTempDir() + defer { try? FileManager.default.removeItem(at: base) } + + try writeSettings( + """ + { "agent": { "image": "from-boite" } } + """, + at: base, folderName: ".boite") + + try writeSettings( + """ + { "agent": { "image": "from-agentc" } } + """, + at: base, folderName: ".agentc") + + let settings = ProjectSettings.find(from: base) + #expect(settings?.agent?.image == "from-boite") + } + + @Test("Falls back to .agentc when .boite is absent") + func fallsBackToAgentc() throws { + let base = makeTempDir() + defer { try? FileManager.default.removeItem(at: base) } + + try writeSettings( + """ + { "agent": { "image": "from-agentc" } } + """, + at: base, folderName: ".agentc") + + let settings = ProjectSettings.find(from: base) + #expect(settings?.agent?.image == "from-agentc") + } + + @Test("Stops at nearest match — does not traverse further") + func stopsAtNearestMatch() throws { + let base = makeTempDir() + let child = base.appendingPathComponent("project") + try FileManager.default.createDirectory(at: child, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + try writeSettings( + """ + { "agent": { "image": "child-image" } } + """, + at: child, folderName: ".agentc") + + try writeSettings( + """ + { "agent": { "image": "parent-image" } } + """, + at: base, folderName: ".agentc") + + let settings = ProjectSettings.find(from: child) + #expect(settings?.agent?.image == "child-image") + } + + @Test("Returns nil when no settings found") + func returnsNilWhenNotFound() throws { + let base = makeTempDir() + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + // No .agentc or .boite folder + let settings = ProjectSettings.find(from: base) + // We can't guarantee nil here since a parent dir might have one, + // but we can test with a deep unique path + let deep = base.appendingPathComponent("a/b/c/d/e") + try FileManager.default.createDirectory(at: deep, withIntermediateDirectories: true) + // Searching from deep should eventually check base (which has nothing) + // and go up. It may or may not find something in system directories, + // but we can verify a known-empty tree returns a consistent result. + // The actual "returns nil" is tested indirectly by the other tests. + } + + @Test("Loads from explicit folder") + func loadsFromExplicitFolder() throws { + let base = makeTempDir() + defer { try? FileManager.default.removeItem(at: base) } + + let folder = base.appendingPathComponent("custom-config") + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + let json = """ + { "agent": { "memoryMiB": 4096 } } + """ + try json.write( + to: folder.appendingPathComponent("settings.json"), + atomically: true, + encoding: .utf8 + ) + + let settings = ProjectSettings.load(fromFolder: folder) + #expect(settings?.agent?.memoryMiB == 4096) + } + + @Test("Returns nil from explicit folder with no settings") + func loadsNilFromEmptyFolder() throws { + let base = makeTempDir() + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + let settings = ProjectSettings.load(fromFolder: base) + #expect(settings == nil) + } + + @Test("Skips malformed JSON gracefully") + func skipsMalformedJson() throws { + let base = makeTempDir() + defer { try? FileManager.default.removeItem(at: base) } + + let dir = base.appendingPathComponent(".agentc") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try "not valid json {{{".write( + to: dir.appendingPathComponent("settings.json"), + atomically: true, + encoding: .utf8 + ) + + let settings = ProjectSettings.find(from: base) + // Malformed JSON is skipped; search continues (and likely finds nothing here) + // The key assertion is that it doesn't throw + _ = settings + } + + @Test("Prefers .boite in child over .agentc in child, even when parent has .agentc") + func prefersBoiteInChildOverAgentcInParent() throws { + let base = makeTempDir() + let child = base.appendingPathComponent("project") + try FileManager.default.createDirectory(at: child, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + try writeSettings( + """ + { "agent": { "image": "parent-agentc" } } + """, + at: base, folderName: ".agentc") + + try writeSettings( + """ + { "agent": { "image": "child-boite" } } + """, + at: child, folderName: ".boite") + + let settings = ProjectSettings.find(from: child) + #expect(settings?.agent?.image == "child-boite") + } +} diff --git a/Tests/AgentcIntegrationTests/AgentcHelpers.swift b/Tests/AgentcIntegrationTests/AgentcHelpers.swift index a7c881e..63aa918 100644 --- a/Tests/AgentcIntegrationTests/AgentcHelpers.swift +++ b/Tests/AgentcIntegrationTests/AgentcHelpers.swift @@ -12,7 +12,8 @@ struct ProcessOutput: Sendable { func runAgentc( args: [String], - env: [String: String] = [:] + env: [String: String] = [:], + cwd: String? = nil ) async -> ProcessOutput { let repoRoot = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() // AgentcHelpers.swift @@ -35,6 +36,10 @@ func runAgentc( process.arguments = args process.environment = environment + if let cwd { + process.currentDirectoryURL = URL(fileURLWithPath: cwd) + } + let stdoutPipe = Pipe() let stderrPipe = Pipe() process.standardOutput = stdoutPipe diff --git a/Tests/AgentcIntegrationTests/ProjectSettingsIntegrationTests.swift b/Tests/AgentcIntegrationTests/ProjectSettingsIntegrationTests.swift new file mode 100644 index 0000000..27ca23a --- /dev/null +++ b/Tests/AgentcIntegrationTests/ProjectSettingsIntegrationTests.swift @@ -0,0 +1,326 @@ +import Foundation +import Testing + +/// Helper to write a project settings file into a temp directory. +private func writeProjectSettings(_ json: String, at base: URL, folderName: String = ".agentc") + throws +{ + let dir = base.appendingPathComponent(folderName) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try json.write( + to: dir.appendingPathComponent("settings.json"), + atomically: true, + encoding: .utf8 + ) +} + +@Suite("Project Settings Integration Tests") +struct ProjectSettingsIntegrationTests { + init() { + _ = sharedProfile + } + + // MARK: - --agentc-folder + + @Test("--agentc-folder applies agent.cpus setting") + func agentcFolderAppliesCpus() async throws { + let base = URL(fileURLWithPath: "/tmp/__TEST_agentc_ps_cpus.\(UUID().uuidString.prefix(6))") + defer { try? FileManager.default.removeItem(at: base) } + + let settingsDir = base.appendingPathComponent("settings") + try writeProjectSettings( + """ + { "agent": { "cpus": 2 } } + """, + at: settingsDir) + + let result = await runAgentc( + args: [ + "sh", + "--profile", sharedProfile, + "--configurations-dir", sharedConfigurationsDir, + "--no-update-image", + "--agentc-folder", settingsDir.appendingPathComponent(".agentc").path, + "--", "nproc", + ] + ) + #expect(result.exitCode == 0) + let reported = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(reported == "2") + } + + @Test("--agentc-folder applies agent.memoryMiB setting") + func agentcFolderAppliesMemory() async throws { + let base = URL(fileURLWithPath: "/tmp/__TEST_agentc_ps_mem.\(UUID().uuidString.prefix(6))") + defer { try? FileManager.default.removeItem(at: base) } + + let settingsDir = base.appendingPathComponent("settings") + let limitMiB = 512 + let limitBytes = limitMiB * 1024 * 1024 + try writeProjectSettings( + """ + { "agent": { "memoryMiB": \(limitMiB) } } + """, + at: settingsDir) + + let result = await runAgentc( + args: [ + "sh", + "--profile", sharedProfile, + "--configurations-dir", sharedConfigurationsDir, + "--no-update-image", + "--agentc-folder", settingsDir.appendingPathComponent(".agentc").path, + "--", "cat", "/sys/fs/cgroup/memory.max", + ] + ) + #expect(result.exitCode == 0) + let reported = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(reported == "\(limitBytes)") + } + + @Test("--agentc-folder applies agent.excludes setting") + func agentcFolderAppliesExcludes() async throws { + let base = URL(fileURLWithPath: "/tmp/__TEST_agentc_ps_excl.\(UUID().uuidString.prefix(6))") + defer { try? FileManager.default.removeItem(at: base) } + + let ws = base.appendingPathComponent("workspace") + let secretDir = ws.appendingPathComponent("secret") + try FileManager.default.createDirectory(at: secretDir, withIntermediateDirectories: true) + try "sensitive".write( + to: secretDir.appendingPathComponent("data.txt"), + atomically: true, encoding: .utf8) + + let settingsDir = base.appendingPathComponent("settings") + try writeProjectSettings( + """ + { "agent": { "excludes": ["secret"] } } + """, + at: settingsDir) + + let containerPath = workspaceContainerPath(for: ws) + + let result = await runAgentc( + args: [ + "sh", + "--profile", sharedProfile, + "--configurations-dir", sharedConfigurationsDir, + "--workspace", ws.path, + "--no-update-image", + "--agentc-folder", settingsDir.appendingPathComponent(".agentc").path, + "--", "ls", "\(containerPath)/secret", + ] + ) + #expect(result.exitCode == 0) + #expect(result.stdout.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + + // MARK: - CLI Override + + @Test("CLI --cpus overrides project settings agent.cpus") + func cliOverridesProjectCpus() async throws { + let base = URL( + fileURLWithPath: "/tmp/__TEST_agentc_ps_ovcpus.\(UUID().uuidString.prefix(6))") + defer { try? FileManager.default.removeItem(at: base) } + + let settingsDir = base.appendingPathComponent("settings") + try writeProjectSettings( + """ + { "agent": { "cpus": 2 } } + """, + at: settingsDir) + + let result = await runAgentc( + args: [ + "sh", + "--profile", sharedProfile, + "--configurations-dir", sharedConfigurationsDir, + "--no-update-image", + "--agentc-folder", settingsDir.appendingPathComponent(".agentc").path, + "--cpus", "3", + "--", "nproc", + ] + ) + #expect(result.exitCode == 0) + let reported = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(reported == "3") + } + + // MARK: - Merge Behavior + + @Test("CLI --exclude and project excludes are both applied") + func mergesExcludes() async throws { + let base = URL( + fileURLWithPath: "/tmp/__TEST_agentc_ps_merge.\(UUID().uuidString.prefix(6))") + defer { try? FileManager.default.removeItem(at: base) } + + let ws = base.appendingPathComponent("workspace") + let secretDir = ws.appendingPathComponent("secret") + let vendorDir = ws.appendingPathComponent("vendor") + try FileManager.default.createDirectory(at: secretDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: vendorDir, withIntermediateDirectories: true) + try "secret-data".write( + to: secretDir.appendingPathComponent("s.txt"), atomically: true, encoding: .utf8) + try "vendor-data".write( + to: vendorDir.appendingPathComponent("v.txt"), atomically: true, encoding: .utf8) + + let settingsDir = base.appendingPathComponent("settings") + try writeProjectSettings( + """ + { "agent": { "excludes": ["vendor"] } } + """, + at: settingsDir) + + let containerPath = workspaceContainerPath(for: ws) + + // CLI excludes "secret", project settings excludes "vendor" — both should be empty + let resultSecret = await runAgentc( + args: [ + "sh", + "--profile", sharedProfile, + "--configurations-dir", sharedConfigurationsDir, + "--workspace", ws.path, + "--no-update-image", + "--agentc-folder", settingsDir.appendingPathComponent(".agentc").path, + "--exclude", "secret", + "--", "ls", "\(containerPath)/secret", + ] + ) + #expect(resultSecret.exitCode == 0) + #expect(resultSecret.stdout.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + let resultVendor = await runAgentc( + args: [ + "sh", + "--profile", sharedProfile, + "--configurations-dir", sharedConfigurationsDir, + "--workspace", ws.path, + "--no-update-image", + "--agentc-folder", settingsDir.appendingPathComponent(".agentc").path, + "--exclude", "secret", + "--", "ls", "\(containerPath)/vendor", + ] + ) + #expect(resultVendor.exitCode == 0) + #expect(resultVendor.stdout.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + + // MARK: - CWD-based Discovery + + @Test("Settings are discovered from CWD without --agentc-folder") + func cwdDiscovery() async throws { + let base = URL(fileURLWithPath: "/tmp/__TEST_agentc_ps_cwd.\(UUID().uuidString.prefix(6))") + defer { try? FileManager.default.removeItem(at: base) } + + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + try writeProjectSettings( + """ + { "agent": { "cpus": 2 } } + """, + at: base) + + let result = await runAgentc( + args: [ + "sh", + "--profile", sharedProfile, + "--configurations-dir", sharedConfigurationsDir, + "--no-update-image", + "--", "nproc", + ], + cwd: base.path + ) + #expect(result.exitCode == 0) + let reported = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(reported == "2") + } + + @Test("Settings are discovered from parent of CWD") + func cwdParentDiscovery() async throws { + let base = URL( + fileURLWithPath: "/tmp/__TEST_agentc_ps_cwdp.\(UUID().uuidString.prefix(6))") + let subdir = base.appendingPathComponent("subproject") + defer { try? FileManager.default.removeItem(at: base) } + + try FileManager.default.createDirectory(at: subdir, withIntermediateDirectories: true) + try writeProjectSettings( + """ + { "agent": { "cpus": 2 } } + """, + at: base) + + let result = await runAgentc( + args: [ + "sh", + "--profile", sharedProfile, + "--configurations-dir", sharedConfigurationsDir, + "--no-update-image", + "--", "nproc", + ], + cwd: subdir.path + ) + #expect(result.exitCode == 0) + let reported = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(reported == "2") + } + + // MARK: - .boite preference + + @Test(".boite folder is preferred over .agentc in CWD discovery") + func boitePreferredInCwd() async throws { + let base = URL( + fileURLWithPath: "/tmp/__TEST_agentc_ps_boite.\(UUID().uuidString.prefix(6))") + defer { try? FileManager.default.removeItem(at: base) } + + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + + try writeProjectSettings( + """ + { "agent": { "cpus": 3 } } + """, + at: base, folderName: ".boite") + + try writeProjectSettings( + """ + { "agent": { "cpus": 1 } } + """, + at: base, folderName: ".agentc") + + let result = await runAgentc( + args: [ + "sh", + "--profile", sharedProfile, + "--configurations-dir", sharedConfigurationsDir, + "--no-update-image", + "--", "nproc", + ], + cwd: base.path + ) + #expect(result.exitCode == 0) + let reported = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(reported == "3") + } + + // MARK: - No settings (defaults unchanged) + + @Test("Without project settings, defaults are preserved") + func noSettingsUsesDefaults() async throws { + let base = URL( + fileURLWithPath: "/tmp/__TEST_agentc_ps_noset.\(UUID().uuidString.prefix(6))") + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + // No .agentc or .boite folder — should use default cpus=1 + let result = await runAgentc( + args: [ + "sh", + "--profile", sharedProfile, + "--configurations-dir", sharedConfigurationsDir, + "--no-update-image", + "--", "nproc", + ], + cwd: base.path + ) + #expect(result.exitCode == 0) + let reported = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(reported == "1") + } +} diff --git a/docs/project-settings.md b/docs/project-settings.md new file mode 100644 index 0000000..a4d8a05 --- /dev/null +++ b/docs/project-settings.md @@ -0,0 +1,136 @@ +# Project Settings + +`agentc` supports per-project configuration via a `settings.json` file placed in an `.agentc/` folder. This lets you commit default agent settings alongside your project so every team member (and CI) gets the same configuration without extra CLI flags. + +## Quick Start + +Create `.agentc/settings.json` in your project root: + +```json +{ + "agent": { + "image": "my-org/dev-image:latest", + "configurations": ["claude"], + "cpus": 4, + "memoryMiB": 4096, + "excludes": ["node_modules", ".git"] + } +} +``` + +Then run `agentc` from anywhere inside the project tree — the settings are automatically discovered. + +## How Settings Are Found + +When `agentc` is invoked, it searches for a settings file starting from the current working directory and walking upward through parent directories until the filesystem root. At each directory level it checks for `settings.json` inside candidate folders. The first valid settings file found wins; the search does not continue further. + +You can override this search by specifying the folder path directly: + +```sh +agentc run --agentc-folder /path/to/settings-folder +``` + +When `--agentc-folder` is used, no upward directory search is performed; the settings file is loaded from the given folder. + +## Settings Schema + +All fields are optional. Only the values you specify take effect. + +```json +{ + "agent": { + "image": "", + "profile": "", + "excludes": ["", ...], + "configurations": ["", ...], + "additionalMounts": ["", ...], + "defaultArguments": ["", ...], + "additionalArguments": ["", ...], + "cpus": "", + "memoryMiB": "", + "bootstrap": "", + "respectImageEntrypoint": "" + } +} +``` + +### Field Reference + +| Field | CLI Equivalent | Description | +|---|---|---| +| `agent.image` | `--image`, `-i` | Default container image reference. | +| `agent.profile` | `--profile`, `-p` | Default profile name. | +| `agent.excludes` | `--exclude` | Workspace sub-folders to mask with empty overlays. | +| `agent.configurations` | `--configurations`, `-c` | Agent configuration names to activate. | +| `agent.additionalMounts` | `--additional-mount` | Additional host directories to mount. | +| `agent.defaultArguments` | positional args after `--` | Default arguments passed to the entrypoint. | +| `agent.additionalArguments` | *(none)* | Arguments always appended to entrypoint args. | +| `agent.cpus` | `--cpus` | Number of CPUs to allocate. | +| `agent.memoryMiB` | `--memory-mib` | Container memory limit in MiB. | +| `agent.bootstrap` | `--bootstrap` | Path to a custom bootstrap/entrypoint script. | +| `agent.respectImageEntrypoint` | `--respect-image-entrypoint` | Use the image's built-in entrypoint. | + +### Override and Merge Rules + +When both CLI flags and project settings specify a value, the behavior depends on the field: + +**Override** (CLI wins, project settings used as fallback): + +- `image`, `profile`, `configurations`, `cpus`, `memoryMiB`, `bootstrap`, `respectImageEntrypoint` + +**Merge** (both sets are combined): + +- `excludes` — CLI and project excludes are all applied. +- `additionalMounts` — CLI and project mounts are all mounted. + +**Arguments have special handling:** + +- `defaultArguments` — Used when no CLI positional arguments are given. If the user passes arguments after `--`, those replace `defaultArguments` entirely. +- `additionalArguments` — Always appended to whatever arguments are in effect (whether from CLI or `defaultArguments`). + +### Priority Chain + +For fields with override behavior, the full priority chain is: + +1. CLI flag (highest priority) +2. Project settings (`settings.json`) +3. Profile settings (`~/.agentc/profiles//settings.json`) — only for `configurations` +4. Built-in default (lowest priority) + +## Examples + +### Minimal: Set a Default Image + +```json +{ + "agent": { + "image": "ghcr.io/my-org/dev:latest" + } +} +``` + +### Team Project with Resource Limits + +```json +{ + "agent": { + "image": "ghcr.io/my-org/dev:latest", + "configurations": ["claude", "copilot"], + "cpus": 4, + "memoryMiB": 8192, + "excludes": ["node_modules", ".git", "dist"], + "additionalArguments": ["--model", "opus"] + } +} +``` + +### Use Image Entrypoint (No Bootstrap) + +```json +{ + "agent": { + "image": "my-custom-agent:latest", + "respectImageEntrypoint": true + } +} +```