Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 6 additions & 11 deletions Packages/CrowClaude/Sources/CrowClaude/ClaudeCodeAgent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@ public struct ClaudeCodeAgent: CodingAgent {

private let launcher: ClaudeLauncher

/// Standard search paths for the Claude CLI binary, in priority order.
static let claudeBinaryCandidates: [String] = [
/// Last-resort search paths for the `claude` binary, used only when the
/// configured `BinaryOverrides` and a PATH walk both miss. Claude Code
/// installs via its installer to `~/.local/bin/claude` by default, which
/// is the first hit on most setups; the homebrew paths cover users who
/// symlinked the CLI into a system location (CROW-484).
public let fallbackCandidates: [String] = [
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".local/bin/claude").path,
"/usr/local/bin/claude",
"/opt/homebrew/bin/claude",
Expand All @@ -32,15 +36,6 @@ public struct ClaudeCodeAgent: CodingAgent {
self.launcher = ClaudeLauncher()
}

public func findBinary() -> String? {
for path in Self.claudeBinaryCandidates {
if FileManager.default.isExecutableFile(atPath: path) {
return path
}
}
return nil
}

public func autoLaunchCommand(
session: Session,
worktreePath: String,
Expand Down
18 changes: 6 additions & 12 deletions Packages/CrowCodex/Sources/CrowCodex/OpenAICodexAgent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,12 @@ public struct OpenAICodexAgent: CodingAgent {

private let launcher: CodexLauncher

/// Standard search paths for the `codex` binary, in priority order.
/// Homebrew-cask installs Codex at the first path on macOS.
static let codexBinaryCandidates: [String] = [
/// Last-resort search paths for the `codex` binary, used only when the
/// configured `BinaryOverrides` and a PATH walk both miss. Most users will
/// resolve through PATH (codex ships via `npm i -g @openai/codex` and
/// lives wherever the user's Node manager puts globals); this list is just
/// the historical hardcoded set we used to check first (CROW-484).
public let fallbackCandidates: [String] = [
"/opt/homebrew/bin/codex",
"/usr/local/bin/codex",
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".local/bin/codex").path,
Expand All @@ -35,15 +38,6 @@ public struct OpenAICodexAgent: CodingAgent {
self.launcher = CodexLauncher()
}

public func findBinary() -> String? {
for path in Self.codexBinaryCandidates {
if FileManager.default.isExecutableFile(atPath: path) {
return path
}
}
return nil
}

public func autoLaunchCommand(
session: Session,
worktreePath: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Testing
@testable import CrowCodex
@testable import CrowCore

@Suite("OpenAICodexAgent")
@Suite("OpenAICodexAgent", .serialized)
struct OpenAICodexAgentTests {
private let agent = OpenAICodexAgent()

Expand Down Expand Up @@ -62,4 +62,29 @@ struct OpenAICodexAgentTests {
// accept either outcome and just verify the result type.
_ = agent.findBinary() // smoke test: must not crash
}

@Test func findBinaryHonorsBinaryOverride() {
// `defaults.binaries.codex` -> absolute path. The default
// `CodingAgent.findBinary()` impl should consult
// `BinaryOverrides.shared` before walking PATH (CROW-484).
// `/bin/sh` is guaranteed-executable on macOS and clearly distinct
// from any real codex install, so a positive result here means the
// override path was honored.
BinaryOverrides.shared.set(["codex": "/bin/sh"])
defer { BinaryOverrides.shared.set([:]) }

#expect(agent.findBinary() == "/bin/sh")
}

@Test func findBinaryIgnoresOverrideWhenPathMissing() {
// A stale override (binary moved/uninstalled after config edit) must
// not break registration outright — fall through to PATH/fallback
// discovery instead. We can't guarantee codex is installed in the
// test env, so we just assert that the bogus override doesn't get
// returned literally.
BinaryOverrides.shared.set(["codex": "/tmp/this-path-does-not-exist-crow484"])
defer { BinaryOverrides.shared.set([:]) }

#expect(agent.findBinary() != "/tmp/this-path-does-not-exist-crow484")
}
}
34 changes: 34 additions & 0 deletions Packages/CrowCore/Sources/CrowCore/Agent/BinaryOverrides.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import Foundation

/// Process-wide explicit overrides for `CodingAgent.findBinary()`. Populated
/// once at app launch from `AppConfig.defaults.binaries` and consulted by the
/// default `findBinary()` impl before falling through to PATH-walk + hardcoded
/// candidates (CROW-484).
///
/// Keyed by `AgentKind` so a config entry like
/// `{ "defaults": { "binaries": { "codex": "/Users/me/.nvm/.../bin/codex" } } }`
/// pins the codex agent's binary to the explicit path the user named — useful
/// when discovery still fails for some exotic install layout.
public final class BinaryOverrides: @unchecked Sendable {
public static let shared = BinaryOverrides()

private let lock = NSLock()
private var paths: [AgentKind: String] = [:]

/// Replace the override map. Keys are `AgentKind.rawValue` strings (matches
/// the JSON config shape and the existing `agentsByKind` keying style).
/// An empty map clears all overrides.
public func set(_ raw: [String: String]) {
lock.lock(); defer { lock.unlock() }
paths = Dictionary(uniqueKeysWithValues: raw.map { (AgentKind(rawValue: $0), $1) })
}

/// The user-configured absolute path for `kind`, or `nil` if none is set.
/// Callers must still verify the path is executable before using it — a
/// stale override (e.g. the binary moved after config edit) should fall
/// through to PATH-walk rather than break agent registration.
public func path(for kind: AgentKind) -> String? {
lock.lock(); defer { lock.unlock() }
return paths[kind]
}
}
35 changes: 35 additions & 0 deletions Packages/CrowCore/Sources/CrowCore/Agent/CodingAgent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ public protocol CodingAgent: Sendable {
/// `AgentStateTransition` values.
var stateSignalSource: any StateSignalSource { get }

/// Last-resort hardcoded paths for the agent's CLI binary, checked after
/// the user's explicit `BinaryOverrides` and a PATH walk both miss. Used
/// only when the user's resolved PATH is unusually narrow (CROW-484). An
/// empty list is fine — most agents will resolve through PATH first.
var fallbackCandidates: [String] { get }

/// Resolve this agent's binary on disk, or return `nil` if it isn't
/// installed. Drives binary-presence gating for the per-session picker
/// and the launch-command builder below.
Expand Down Expand Up @@ -98,6 +104,35 @@ public protocol CodingAgent: Sendable {
}

public extension CodingAgent {
/// Default empty `fallbackCandidates` so simple conformers don't have to
/// declare an empty array. Agents with known install locations should
/// override this.
var fallbackCandidates: [String] { [] }

/// Default binary discovery: explicit `BinaryOverrides` → PATH walk
/// (using the agent's `launchCommandToken` as the binary name) →
/// hardcoded `fallbackCandidates`. Returns the first resolved absolute
/// path, or `nil` if nothing matches.
///
/// This replaces the per-agent hardcoded-list-only impl that left
/// nvm/Volta/pnpm/asdf installs invisible to Crow (CROW-484).
func findBinary() -> String? {
let fm = FileManager.default
// 1. Explicit user override from `defaults.binaries.<kind>`. Verify
// the path is still executable so a stale override falls through
// to discovery rather than breaking registration outright.
if let configured = BinaryOverrides.shared.path(for: kind),
fm.isExecutableFile(atPath: configured) {
return configured
}
// 2. Walk the user's resolved PATH the same way `command -v` does.
if let found = ShellEnvironment.shared.findExecutable(launchCommandToken) {
return found
}
// 3. Hardcoded last-resort fallback covers the exotic-PATH case.
return fallbackCandidates.first { fm.isExecutableFile(atPath: $0) }
}

/// Default Manager launch command: invoke the agent's CLI binary by
/// name with no extra flags. The terminal backend (tmux/Ghostty) owns
/// the submitting Enter — return the raw command without a trailing
Expand Down
13 changes: 11 additions & 2 deletions Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,12 @@ public struct ConfigDefaults: Codable, Sendable, Equatable {
public var excludeReviewRepos: [String]
public var excludeTicketRepos: [String]
public var ignoreReviewLabels: [String]
/// Explicit absolute-path overrides for `CodingAgent` binary discovery,
/// keyed by `AgentKind.rawValue` (e.g. `"codex"`, `"cursor"`, `"claude-code"`).
/// Consulted before the PATH walk in `CodingAgent.findBinary()` — set this
/// when discovery doesn't find your install for any reason (exotic Node
/// manager, sandboxed PATH, etc.). See CROW-484.
public var binaries: [String: String]

/// Characters that are invalid in git ref names (see `git check-ref-format`).
private static let invalidBranchChars = CharacterSet(charactersIn: " ~^:?*[\\")
Expand All @@ -432,7 +438,8 @@ public struct ConfigDefaults: Codable, Sendable, Equatable {
excludeDirs: [String] = ["node_modules", ".git", "vendor", "dist", "build", "target"],
excludeReviewRepos: [String] = [],
excludeTicketRepos: [String] = [],
ignoreReviewLabels: [String] = []
ignoreReviewLabels: [String] = [],
binaries: [String: String] = [:]
) {
self.provider = provider
self.cli = cli
Expand All @@ -441,6 +448,7 @@ public struct ConfigDefaults: Codable, Sendable, Equatable {
self.excludeReviewRepos = excludeReviewRepos
self.excludeTicketRepos = excludeTicketRepos
self.ignoreReviewLabels = ignoreReviewLabels
self.binaries = binaries
}

public init(from decoder: Decoder) throws {
Expand All @@ -452,10 +460,11 @@ public struct ConfigDefaults: Codable, Sendable, Equatable {
excludeReviewRepos = try container.decodeIfPresent([String].self, forKey: .excludeReviewRepos) ?? []
excludeTicketRepos = try container.decodeIfPresent([String].self, forKey: .excludeTicketRepos) ?? []
ignoreReviewLabels = try container.decodeIfPresent([String].self, forKey: .ignoreReviewLabels) ?? []
binaries = try container.decodeIfPresent([String: String].self, forKey: .binaries) ?? [:]
}

private enum CodingKeys: String, CodingKey {
case provider, cli, branchPrefix, excludeDirs, excludeReviewRepos, excludeTicketRepos, ignoreReviewLabels
case provider, cli, branchPrefix, excludeDirs, excludeReviewRepos, excludeTicketRepos, ignoreReviewLabels, binaries
}
}

Expand Down
18 changes: 14 additions & 4 deletions Packages/CrowCore/Sources/CrowCore/ShellEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,26 @@ public final class ShellEnvironment: Sendable {
env.merging(extra) { _, new in new }
}

/// Returns `true` if `name` is an executable found in the resolved PATH.
public func hasCommand(_ name: String) -> Bool {
/// Returns the absolute path of `name` if it resolves to an executable on
/// the user's PATH, or `nil` otherwise. Mirrors the behavior of shell
/// `command -v` — walks `resolvedPATH` left-to-right and returns the first
/// hit. Used by every `CodingAgent` to find its CLI binary regardless of
/// where the user's package manager installed it (npm-global / nvm /
/// volta / pnpm / asdf — see CROW-484).
public func findExecutable(_ name: String) -> String? {
let fm = FileManager.default
for dir in resolvedPATH.split(separator: ":") {
let path = "\(dir)/\(name)"
if fm.isExecutableFile(atPath: path) {
return true
return path
}
}
return false
return nil
}

/// Returns `true` if `name` is an executable found in the resolved PATH.
public func hasCommand(_ name: String) -> Bool {
findExecutable(name) != nil
}

// MARK: - PATH Resolution
Expand Down
32 changes: 31 additions & 1 deletion Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import Testing
WorkspaceInfo(name: "TestOrg", provider: "github", cli: "gh", alwaysInclude: ["repo1"]),
WorkspaceInfo(name: "GitLabOrg", provider: "gitlab", cli: "glab", host: "gitlab.example.com"),
],
defaults: ConfigDefaults(provider: "gitlab", cli: "glab", branchPrefix: "fix/", excludeDirs: ["vendor"], excludeReviewRepos: ["zarf-dev/zarf", "bmlt-enabled/yap"], excludeTicketRepos: ["org/hidden-repo"]),
defaults: ConfigDefaults(provider: "gitlab", cli: "glab", branchPrefix: "fix/", excludeDirs: ["vendor"], excludeReviewRepos: ["zarf-dev/zarf", "bmlt-enabled/yap"], excludeTicketRepos: ["org/hidden-repo"], binaries: ["codex": "/tmp/codex"]),
notifications: NotificationSettings(globalMute: true),
sidebar: SidebarSettings(hideSessionDetails: true)
)
Expand All @@ -27,10 +27,40 @@ import Testing
#expect(decoded.defaults.excludeDirs == ["vendor"])
#expect(decoded.defaults.excludeReviewRepos == ["zarf-dev/zarf", "bmlt-enabled/yap"])
#expect(decoded.defaults.excludeTicketRepos == ["org/hidden-repo"])
#expect(decoded.defaults.binaries == ["codex": "/tmp/codex"])
#expect(decoded.notifications.globalMute == true)
#expect(decoded.sidebar.hideSessionDetails == true)
}

/// `defaults.binaries` decodes from explicit JSON and is keyed by
/// `AgentKind.rawValue` (CROW-484).
@Test func configDefaultsBinariesDecodesFromJSON() throws {
let json = #"""
{
"defaults": {
"binaries": {
"codex": "/Users/me/.nvm/versions/node/v22/bin/codex",
"cursor": "/Users/me/.bun/bin/agent",
"claude-code": "/Users/me/.local/bin/claude"
}
}
}
"""#.data(using: .utf8)!
let config = try JSONDecoder().decode(AppConfig.self, from: json)

#expect(config.defaults.binaries["codex"] == "/Users/me/.nvm/versions/node/v22/bin/codex")
#expect(config.defaults.binaries["cursor"] == "/Users/me/.bun/bin/agent")
#expect(config.defaults.binaries["claude-code"] == "/Users/me/.local/bin/claude")
}

/// Missing `binaries` key decodes to an empty map (forward-compat with
/// existing config files written before CROW-484).
@Test func configDefaultsBinariesDefaultsEmpty() throws {
let json = #"{ "defaults": { "branchPrefix": "fix/" } }"#.data(using: .utf8)!
let config = try JSONDecoder().decode(AppConfig.self, from: json)
#expect(config.defaults.binaries.isEmpty)
}

@Test func appConfigDecodeFromEmptyJSON() throws {
let json = "{}".data(using: .utf8)!
let config = try JSONDecoder().decode(AppConfig.self, from: json)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import Foundation
import Testing
@testable import CrowCore

@Suite("BinaryOverrides", .serialized)
struct BinaryOverridesTests {
/// Resets the shared override state at the end of the suite so other
/// tests don't see leaked entries. Each test that mutates state is
/// responsible for resetting before returning, but this is belt + braces.
init() {
BinaryOverrides.shared.set([:])
}

@Test func emptyByDefault() {
BinaryOverrides.shared.set([:])
#expect(BinaryOverrides.shared.path(for: .codex) == nil)
#expect(BinaryOverrides.shared.path(for: .cursor) == nil)
#expect(BinaryOverrides.shared.path(for: .claudeCode) == nil)
}

@Test func setStoresRawKeyedByAgentKind() {
BinaryOverrides.shared.set([
"codex": "/Users/me/.nvm/versions/node/v22/bin/codex",
"cursor": "/tmp/agent",
])
defer { BinaryOverrides.shared.set([:]) }

#expect(BinaryOverrides.shared.path(for: .codex) == "/Users/me/.nvm/versions/node/v22/bin/codex")
#expect(BinaryOverrides.shared.path(for: .cursor) == "/tmp/agent")
#expect(BinaryOverrides.shared.path(for: .claudeCode) == nil)
}

@Test func setReplacesPreviousMap() {
BinaryOverrides.shared.set(["codex": "/tmp/codex-a"])
BinaryOverrides.shared.set(["cursor": "/tmp/agent"])
defer { BinaryOverrides.shared.set([:]) }

// Previous codex entry is gone — `set` replaces, not merges.
#expect(BinaryOverrides.shared.path(for: .codex) == nil)
#expect(BinaryOverrides.shared.path(for: .cursor) == "/tmp/agent")
}

@Test func unknownAgentKindRoundTrips() {
// AgentKind is a RawRepresentable struct so downstream packages can
// register their own kinds. The override map should accept and
// surface arbitrary keys.
BinaryOverrides.shared.set(["custom-agent": "/tmp/custom"])
defer { BinaryOverrides.shared.set([:]) }

#expect(BinaryOverrides.shared.path(for: AgentKind(rawValue: "custom-agent")) == "/tmp/custom")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Foundation
import Testing
@testable import CrowCore

@Suite("ShellEnvironment")
struct ShellEnvironmentTests {
/// `sh` is guaranteed present in `/bin/sh` on macOS, and `/bin` is part of
/// any sane PATH (login shell or fallback). The exact resolved path may be
/// `/bin/sh` or a homebrew-installed sh earlier in PATH — we just verify
/// the helper returns *something* executable matching the requested name.
@Test func findExecutableResolvesKnownBinary() {
let resolved = ShellEnvironment.shared.findExecutable("sh")
#expect(resolved != nil)
if let resolved {
#expect(FileManager.default.isExecutableFile(atPath: resolved))
#expect(resolved.hasSuffix("/sh"))
}
}

@Test func findExecutableReturnsNilForUnknownBinary() {
// A name no PATH directory should plausibly contain.
let resolved = ShellEnvironment.shared.findExecutable("crow-nonexistent-binary-xyzzy-12345")
#expect(resolved == nil)
}

@Test func hasCommandAgreesWithFindExecutable() {
// The two helpers must stay in sync — `hasCommand` is implemented as
// `findExecutable(_:) != nil`, but pin the contract with a test.
#expect(ShellEnvironment.shared.hasCommand("sh") == true)
#expect(ShellEnvironment.shared.hasCommand("crow-nonexistent-binary-xyzzy-12345") == false)
}
}
Loading
Loading