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
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ export CROW_SENTINEL
if [ -n "${CROW_AGENT_KIND:-}" ]; then export CROW_AGENT_KIND; fi
if [ -n "${CROW_AGENT_DISPLAY_NAME:-}" ]; then export CROW_AGENT_DISPLAY_NAME; fi

# CROW-487: per-devroot bin dir holds symlinks for every configured
# `defaults.binaries.<name>`. We export it here so the embedded zsh / bash
# rc files (below) can prepend it to PATH *after* sourcing the user's rc —
# that's the only insertion point that survives the user doing
# `export PATH=…` in `.zshrc`. The Swift side already seeded PATH with this
# dir in front via tmux `new-window -e`, so non-rc shells (fish, custom)
# also resolve symlinks correctly.
if [ -n "${CROW_BIN_DIR:-}" ]; then export CROW_BIN_DIR; fi

# CROW_WRAPPER_LOG is optional. Default to /dev/null so the helper is always
# safe to call without an unset-var guard. Issue #256.
CROW_WRAPPER_LOG="${CROW_WRAPPER_LOG:-/dev/null}"
Expand Down Expand Up @@ -95,6 +104,17 @@ else
crow_log "user_rc_skipped reason=missing rc=$ZDOTDIR/.zshrc"
fi

# CROW-487: re-prepend the Crow-managed bin dir AFTER user rc, so a user
# `export PATH=…` in `.zshrc` cannot shadow the symlink farm. Skip the
# re-prepend when the dir is already at the front (avoids unbounded growth
# if a new shell is exec'd inside the existing one).
if [ -n "${CROW_BIN_DIR:-}" ] && [ -d "$CROW_BIN_DIR" ]; then
case ":$PATH:" in
"$CROW_BIN_DIR:"*) ;; # already first — nothing to do
*) export PATH="$CROW_BIN_DIR:$PATH" ;;
esac
fi

_crow_precmd() {
crow_log "precmd_fired"
if [ -n "${TMUX:-}" ]; then
Expand Down Expand Up @@ -153,6 +173,14 @@ if [ -f "$HOME/.bashrc" ]; then
else
crow_log "user_rc_skipped reason=missing rc=$HOME/.bashrc"
fi
# CROW-487: re-prepend the Crow-managed bin dir AFTER user rc — see the
# zsh branch above for rationale.
if [ -n "${CROW_BIN_DIR:-}" ] && [ -d "$CROW_BIN_DIR" ]; then
case ":$PATH:" in
"$CROW_BIN_DIR:"*) ;; # already first
*) export PATH="$CROW_BIN_DIR:$PATH" ;;
esac
fi
_crow_precmd() {
crow_log "precmd_fired"
if [ -n "${TMUX:-}" ]; then
Expand Down
21 changes: 20 additions & 1 deletion Packages/CrowTerminal/Sources/CrowTerminal/TmuxBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,17 @@ public final class TmuxBackend {
/// AppDelegate guarantees only one owner).
public private(set) var socketPath: String = ""

public func configure(tmuxBinary: String, socketPath: String) {
/// Per-devroot bin dir containing symlinks for `defaults.binaries.<name>`
/// (CROW-487). When non-empty, `registerTerminal` exports `CROW_BIN_DIR`
/// into the spawned tmux window and seeds the window's `PATH` with this
/// directory in front. The shell wrapper re-prepends it after sourcing
/// the user's rc so a user `export PATH=…` can't shadow the symlink farm.
public private(set) var crowBinDir: String = ""

public func configure(tmuxBinary: String, socketPath: String, crowBinDir: String = "") {
self.tmuxBinary = tmuxBinary
self.socketPath = socketPath
self.crowBinDir = crowBinDir
}

// MARK: - Lifecycle
Expand Down Expand Up @@ -222,6 +230,17 @@ public final class TmuxBackend {
}
if !cwd.isEmpty { env["PWD"] = cwd }

// CROW-487: hand the per-devroot bin dir to the wrapper so it can
// prepend it to PATH *after* user rc sourcing — that's the only
// insertion point that survives `export PATH=…` in `.zshrc`. We also
// seed the window's PATH directly so non-rc shells (fish, the
// unknown-shell fallback branch of the wrapper, processes that
// bypass the wrapper entirely) still find the symlink farm.
if !crowBinDir.isEmpty {
env["CROW_BIN_DIR"] = crowBinDir
env["PATH"] = "\(crowBinDir):\(ShellEnvironment.shared.resolvedPATH)"
}

let windowIndex = try ctrl.newWindow(
name: name,
cwd: cwd.isEmpty ? nil : cwd,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,36 @@ struct TmuxBackendTests {
}
}

// MARK: - CROW-487 crowBinDir propagation

/// `configure(...)` stores the per-devroot bin dir so `registerTerminal` can
/// inject `CROW_BIN_DIR` (consumed by the shell wrapper to win PATH precedence
/// after user rc sourcing — see crow-shell-wrapper.sh). Separate suite so it
/// runs even when tmux isn't installed — the integration suite is gated on
/// `discoveredTmuxBinary != nil`, but this check is pure state propagation.
@MainActor
@Suite("TmuxBackend crowBinDir")
struct TmuxBackendCrowBinDirTests {
@Test func configurePropagatesCrowBinDir() {
let backend = TmuxBackend()
backend.configure(
tmuxBinary: "/usr/bin/tmux",
socketPath: "/tmp/crow-487-probe.sock",
crowBinDir: "/devroot/.claude/bin"
)
#expect(backend.crowBinDir == "/devroot/.claude/bin")
}

@Test func configureDefaultsCrowBinDirToEmpty() {
let backend = TmuxBackend()
backend.configure(
tmuxBinary: "/usr/bin/tmux",
socketPath: "/tmp/crow-487-probe.sock"
)
#expect(backend.crowBinDir == "")
}
}

/// Pure-policy tests for `shouldReconcile`. No tmux required, so the suite is
/// always enabled (unlike the integration suite above).
@Suite("TmuxBackend stale-config policy")
Expand Down
15 changes: 11 additions & 4 deletions Sources/Crow/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
_ = try scaffolder.scaffold(
workspaceNames: config.workspaces.map(\.name),
managerAgentKind: config.agentKind(for: .manager),
corveilBinaryPath: nil
corveilBinaryPath: nil,
binaryOverrides: config.defaults.binaries
)

// Save config
Expand Down Expand Up @@ -394,7 +395,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let tmpdir = ProcessInfo.processInfo.environment["TMPDIR"] ?? "/tmp"
let socketPath = (tmpdir as NSString)
.appendingPathComponent("crow-tmux.sock")
TmuxBackend.shared.configure(tmuxBinary: tmuxBinary, socketPath: socketPath)
TmuxBackend.shared.configure(
tmuxBinary: tmuxBinary,
socketPath: socketPath,
crowBinDir: (devRoot as NSString).appendingPathComponent(".claude/bin")
)
TmuxBackend.shared.onUnresponsive = { [weak self] error in
Task { @MainActor in self?.handleTmuxUnresponsive(error: error) }
}
Expand All @@ -410,7 +415,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let result = try scaffolder.scaffold(
workspaceNames: config.workspaces.map(\.name),
managerAgentKind: config.agentKind(for: .manager),
corveilBinaryPath: config.defaults.binaries["corveil"]
corveilBinaryPath: config.defaults.binaries["corveil"],
binaryOverrides: config.defaults.binaries
)
appState.corveilSkillInstallWarning = result.warning
} catch {
Expand Down Expand Up @@ -1078,7 +1084,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let result = try scaffolder.scaffold(
workspaceNames: cfg?.workspaces.map(\.name) ?? [],
managerAgentKind: cfg?.agentKind(for: .manager) ?? .claudeCode,
corveilBinaryPath: cfg?.defaults.binaries["corveil"]
corveilBinaryPath: cfg?.defaults.binaries["corveil"],
binaryOverrides: cfg?.defaults.binaries ?? [:]
)
// Always assign — clears a stale warning from a prior
// launch when the install now succeeds (`result.warning`
Expand Down
79 changes: 78 additions & 1 deletion Sources/Crow/App/Scaffolder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,19 @@ struct Scaffolder {
/// user's locally-built corveil binary (CROW-482). Failures here are
/// non-fatal: they are returned as `ScaffoldResult.warning` and never
/// throw — the rest of the scaffold has already succeeded by that point.
///
/// `binaryOverrides` is the full `defaults.binaries` map. Every entry
/// whose target is executable becomes a symlink at
/// `{devRoot}/.claude/bin/<name>` (CROW-487). Combined with the shell
/// wrapper's PATH prepend, that dir wins precedence for bare invocations
/// of `corveil` / `codex` / `cursor` inside spawned agent terminals, so
/// embedded skills (e.g. `/query-corveil`) resolve to the user-configured
/// binary instead of whatever happens to be on PATH.
@discardableResult
func scaffold(workspaceNames: [String],
managerAgentKind: AgentKind = .claudeCode,
corveilBinaryPath: String? = nil) throws -> ScaffoldResult {
corveilBinaryPath: String? = nil,
binaryOverrides: [String: String] = [:]) throws -> ScaffoldResult {
let fm = FileManager.default

// Create devRoot
Expand Down Expand Up @@ -132,13 +141,81 @@ struct Scaffolder {
let promptsDir = (claudeDir as NSString).appendingPathComponent("prompts")
try fm.createDirectory(atPath: promptsDir, withIntermediateDirectories: true)

// Per-devroot bin dir is the precedence anchor for bare-command
// invocations inside spawned agent terminals (CROW-487). Every
// configured `defaults.binaries.<name>` becomes a symlink here, and
// the tmux shell wrapper prepends this dir to PATH after sourcing
// user rc — so `corveil`, `codex`, `cursor` resolve to the
// user-configured binary regardless of what's on PATH.
installBinarySymlinks(binaryOverrides, claudeDir: claudeDir)

// Re-install the embedded /query-corveil slash command from the
// user-configured corveil binary on every launch (CROW-482). Failure
// here is intentionally non-fatal — the rest of the scaffold is done.
let warning = installCorveilSkill(corveilBinaryPath)
return ScaffoldResult(warning: warning)
}

/// Materialize `{devRoot}/.claude/bin/<name>` symlinks for every
/// `defaults.binaries.<name>` whose target is an executable file
/// (CROW-487). Idempotent — re-run on every Scaffolder pass:
///
/// - Reaps symlinks whose key was removed from config, so a stale entry
/// never shadows a working PATH install. Only removes entries that are
/// actually symlinks (we never own non-link files in this dir).
/// - Skips non-executable / empty targets, dropping any prior link for
/// that key. Prevents a misconfigured path from hiding `corveil` on
/// the user's PATH.
/// - Recreates good links with `removeItem` + `createSymbolicLink`,
/// matching `ln -sf` semantics.
///
/// All errors are logged + swallowed; this step is best-effort and must
/// never fail an otherwise-successful scaffold pass.
private func installBinarySymlinks(_ overrides: [String: String], claudeDir: String) {
let fm = FileManager.default
let binDir = (claudeDir as NSString).appendingPathComponent("bin")
do {
try fm.createDirectory(atPath: binDir, withIntermediateDirectories: true)
} catch {
NSLog("[Scaffolder] could not create bin dir %@: %@", binDir, error.localizedDescription)
return
}

// Reap stale symlinks whose key is no longer in config. Skip
// anything that isn't a symlink — we never want to nuke a real
// file that someone dropped here by hand.
let existing = (try? fm.contentsOfDirectory(atPath: binDir)) ?? []
for name in existing where overrides[name] == nil {
let link = (binDir as NSString).appendingPathComponent(name)
if let attrs = try? fm.attributesOfItem(atPath: link),
(attrs[.type] as? FileAttributeType) == .typeSymbolicLink {
try? fm.removeItem(atPath: link)
}
}

for (name, target) in overrides {
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
let link = (binDir as NSString).appendingPathComponent(name)
guard !trimmed.isEmpty, fm.isExecutableFile(atPath: trimmed) else {
// Misconfigured: drop any stale link for this key so a
// broken pointer doesn't shadow a working PATH install.
try? fm.removeItem(atPath: link)
if !trimmed.isEmpty {
NSLog("[Scaffolder] defaults.binaries.%@ not executable at %@ — skipping symlink",
name, trimmed)
}
continue
}
try? fm.removeItem(atPath: link)
do {
try fm.createSymbolicLink(atPath: link, withDestinationPath: trimmed)
} catch {
NSLog("[Scaffolder] failed to symlink %@ -> %@: %@",
link, trimmed, error.localizedDescription)
}
}
}

/// Runs `<corveilBinaryPath> skill install --path {devRoot}/.claude/commands/query-corveil.md`
/// when the path is set and points at an executable. Returns a short
/// user-facing warning string on failure; `nil` on success or when the
Expand Down
Loading
Loading