Skip to content

Commit 7e379f2

Browse files
committed
🤖 feat: add ~/.mux/bashrc for shell environment customization
When mux launches from Applications (not terminal), agent bash commands get a minimal PATH missing user tools. The standard ~/.bashrc isn't sourced because: 1. `bash -c` creates a non-interactive shell 2. Most bashrc files have interactivity guards that skip content This adds a dedicated ~/.mux/bashrc file that is always sourced before every bash command, allowing users to set up: - PATH modifications (Homebrew, ~/bin, etc.) - Nix profile sourcing - direnv hooks (auto-loads .envrc per directory) - pyenv/rbenv/nvm/asdf initialization Implementation: - LocalBaseRuntime.exec() prepends bashrc sourcing to all commands - SSHRuntime.exec() prepends bashrc sourcing (uses remote ~/.mux/bashrc) - Init hooks also source bashrc for consistency The snippet uses `[ -f $HOME/.mux/bashrc ] && . $HOME/.mux/bashrc || true` to ensure commands don't fail when the file doesn't exist. Fixes #797 _Generated with mux_
1 parent 2820a98 commit 7e379f2

File tree

7 files changed

+305
-4
lines changed

7 files changed

+305
-4
lines changed

docs/SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
- [Instruction Files](./instruction-files.md)
2424
- [Project Secrets](./project-secrets.md)
2525
- [Agentic Git Identity](./agentic-git-identity.md)
26+
- [Shell Environment](./bashrc.md)
2627

2728
# Advanced
2829

docs/bashrc.md

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# Shell Environment (`~/.mux/bashrc`)
2+
3+
Customize the shell environment for agent bash commands by creating a `~/.mux/bashrc` file.
4+
5+
## Why This Exists
6+
7+
When mux runs bash commands (via `bash -c "command"`), the shell is **non-interactive** and **doesn't source `~/.bashrc`**. Most users have interactivity guards in their bashrc that skip content in non-interactive shells:
8+
9+
```bash
10+
# Common pattern in ~/.bashrc that skips setup for non-interactive shells
11+
[[ $- != *i* ]] && return
12+
```
13+
14+
This means:
15+
16+
- **Launching from Applications** — PATH is minimal (`/usr/bin:/bin:/usr/sbin:/sbin`)
17+
- **Nix/direnv users** — Shell customizations aren't applied
18+
- **Homebrew/pyenv/rbenv** — Tools not in PATH
19+
20+
The `~/.mux/bashrc` file is **always sourced** before every bash command, giving you a place to set up the environment reliably.
21+
22+
## Setup
23+
24+
Create `~/.mux/bashrc`:
25+
26+
```bash
27+
mkdir -p ~/.mux
28+
touch ~/.mux/bashrc
29+
```
30+
31+
Add your shell customizations:
32+
33+
```bash
34+
# ~/.mux/bashrc
35+
36+
# Add Homebrew to PATH (macOS)
37+
eval "$(/opt/homebrew/bin/brew shellenv)"
38+
39+
# Add ~/bin to PATH
40+
export PATH="$HOME/bin:$PATH"
41+
```
42+
43+
## Examples
44+
45+
### Nix Users
46+
47+
```bash
48+
# ~/.mux/bashrc
49+
50+
# Source nix profile
51+
if [ -e "$HOME/.nix-profile/etc/profile.d/nix.sh" ]; then
52+
. "$HOME/.nix-profile/etc/profile.d/nix.sh"
53+
fi
54+
55+
# Enable direnv (auto-loads .envrc per directory)
56+
eval "$(direnv hook bash)"
57+
```
58+
59+
### Python (pyenv)
60+
61+
```bash
62+
# ~/.mux/bashrc
63+
export PYENV_ROOT="$HOME/.pyenv"
64+
export PATH="$PYENV_ROOT/bin:$PATH"
65+
eval "$(pyenv init -)"
66+
```
67+
68+
### Node.js (nvm)
69+
70+
```bash
71+
# ~/.mux/bashrc
72+
export NVM_DIR="$HOME/.nvm"
73+
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
74+
```
75+
76+
### Ruby (rbenv)
77+
78+
```bash
79+
# ~/.mux/bashrc
80+
eval "$(rbenv init -)"
81+
```
82+
83+
### Multiple Tools (asdf)
84+
85+
```bash
86+
# ~/.mux/bashrc
87+
. "$HOME/.asdf/asdf.sh"
88+
```
89+
90+
## Behavior
91+
92+
- **Sourced before every bash command** — including init hooks
93+
- **Silently skipped if missing** — no bashrc file = no effect
94+
- **Errors propagate** — if your bashrc has errors, they appear in command output
95+
- **SSH workspaces** — the remote `~/.mux/bashrc` is sourced (you manage it on the remote host)
96+
97+
## Comparison with Init Hooks
98+
99+
| Feature | `~/.mux/bashrc` | `.mux/init` |
100+
| ------- | --------------------- | -------------------------- |
101+
| When | Every bash command | Once at workspace creation |
102+
| Scope | Global (all projects) | Per-project |
103+
| Purpose | Shell environment | Project build/install |
104+
| Errors | Command fails | Logged, non-blocking |
105+
106+
Use **bashrc** for environment (PATH, tools, direnv hooks).
107+
Use **init hooks** for project setup (install dependencies, build).
108+
109+
## Troubleshooting
110+
111+
### Commands Not Finding Tools
112+
113+
If tools aren't found, check that bashrc is being sourced:
114+
115+
```bash
116+
# In mux, run:
117+
echo "bashrc: $MUX_BASHRC_SOURCED"
118+
```
119+
120+
If empty, your bashrc might not exist. If you want to confirm it's being sourced, add to your bashrc:
121+
122+
```bash
123+
# ~/.mux/bashrc
124+
export MUX_BASHRC_SOURCED=1
125+
```
126+
127+
### Bashrc Errors
128+
129+
Errors in your bashrc will cause commands to fail. Test your bashrc:
130+
131+
```bash
132+
bash -c '[ -f "$HOME/.mux/bashrc" ] && . "$HOME/.mux/bashrc" && echo ok'
133+
```
134+
135+
### Performance
136+
137+
The bashrc runs before every command. Keep it fast:
138+
139+
- Avoid expensive operations (network calls, slow init scripts)
140+
- Use lazy loading where possible
141+
- Profile with `time bash -c '. ~/.mux/bashrc'`
142+
143+
## Related
144+
145+
- [Init Hooks](./init-hooks.md) — Per-project initialization scripts
146+
- [Project Secrets](./project-secrets.md) — Environment variables for API keys

src/common/constants/paths.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { describe, expect, it } from "bun:test";
2+
import { getMuxBashrcSourceSnippet } from "./paths";
3+
4+
describe("getMuxBashrcSourceSnippet", () => {
5+
it("should return a bash snippet that sources ~/.mux/bashrc if it exists", () => {
6+
const snippet = getMuxBashrcSourceSnippet();
7+
8+
// Should check for file existence with -f
9+
expect(snippet).toContain("[ -f");
10+
// Should reference $HOME/.mux/bashrc
11+
expect(snippet).toContain('$HOME/.mux/bashrc"');
12+
// Should source the file with . (dot command)
13+
expect(snippet).toContain(". ");
14+
});
15+
16+
it("should use $HOME for portability across local and SSH runtimes", () => {
17+
const snippet = getMuxBashrcSourceSnippet();
18+
19+
// Should not use ~ (tilde) which doesn't expand in all contexts
20+
expect(snippet).not.toContain("~/");
21+
// Should use $HOME which expands reliably
22+
expect(snippet).toContain("$HOME/");
23+
});
24+
25+
it("should silently skip if file doesn't exist and return success", () => {
26+
const snippet = getMuxBashrcSourceSnippet();
27+
28+
// Should use the pattern: [ -f file ] && . file || true
29+
// - If file doesn't exist, [ -f file ] returns 1, && short-circuits, || true returns 0
30+
// - If file exists, [ -f file ] returns 0, && sources file, || is skipped
31+
// The || true is critical for SSH runtime where commands are joined with &&
32+
expect(snippet).toContain("] && .");
33+
expect(snippet).toContain("|| true");
34+
});
35+
});

src/common/constants/paths.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,30 @@ export function getMuxExtensionMetadataPath(rootDir?: string): string {
116116
const root = rootDir ?? getMuxHome();
117117
return join(root, "extensionMetadata.json");
118118
}
119+
120+
/**
121+
* Filename for the user's mux bashrc file.
122+
* This is sourced before every bash command to set up the shell environment.
123+
*
124+
* Unlike ~/.bashrc, this file is always sourced (even in non-interactive shells)
125+
* because `bash -c` doesn't source ~/.bashrc by default, and most users have
126+
* interactivity guards in their bashrc that skip content for non-interactive shells.
127+
*
128+
* Users can put PATH modifications, nix profile sourcing, direnv hooks, etc. here.
129+
*/
130+
export const MUX_BASHRC_FILENAME = "bashrc";
131+
132+
/**
133+
* Get the bash snippet to source ~/.mux/bashrc if it exists.
134+
* Uses $HOME to work correctly on both local and SSH runtimes.
135+
*
136+
* @returns Bash snippet to prepend to commands
137+
*/
138+
export function getMuxBashrcSourceSnippet(): string {
139+
// Use $HOME/.mux/bashrc to work on both local and SSH runtimes
140+
// The pattern `[ -f file ] && . file || true` ensures:
141+
// 1. If file exists: source it, return its exit status (typically 0)
142+
// 2. If file doesn't exist: [ -f ] returns 1, && short-circuits, || true returns 0
143+
// This is critical for SSH runtime where commands are joined with &&
144+
return `[ -f "$HOME/.mux/bashrc" ] && . "$HOME/.mux/bashrc" || true`;
145+
}

src/node/runtime/LocalBaseRuntime.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
createLineBufferedLoggers,
2929
getInitHookEnv,
3030
} from "./initHook";
31+
import { getMuxBashrcSourceSnippet } from "@/common/constants/paths";
3132

3233
/**
3334
* Abstract base class for local runtimes (both WorktreeRuntime and LocalRuntime).
@@ -67,15 +68,19 @@ export abstract class LocalBaseRuntime implements Runtime {
6768
);
6869
}
6970

71+
// Prepend bashrc sourcing to set up environment (PATH, nix, direnv, etc.)
72+
// This is necessary because `bash -c` doesn't source ~/.bashrc
73+
const commandWithBashrc = `${getMuxBashrcSourceSnippet()}\n${command}`;
74+
7075
// If niceness is specified on Unix/Linux, spawn nice directly to avoid escaping issues
7176
// Windows doesn't have nice command, so just spawn bash directly
7277
const isWindows = process.platform === "win32";
7378
const bashPath = getBashPath();
7479
const spawnCommand = options.niceness !== undefined && !isWindows ? "nice" : bashPath;
7580
const spawnArgs =
7681
options.niceness !== undefined && !isWindows
77-
? ["-n", options.niceness.toString(), bashPath, "-c", command]
78-
: ["-c", command];
82+
? ["-n", options.niceness.toString(), bashPath, "-c", commandWithBashrc]
83+
: ["-c", commandWithBashrc];
7984

8085
const childProcess = spawn(spawnCommand, spawnArgs, {
8186
cwd,
@@ -366,7 +371,9 @@ export abstract class LocalBaseRuntime implements Runtime {
366371

367372
return new Promise<void>((resolve) => {
368373
const bashPath = getBashPath();
369-
const proc = spawn(bashPath, ["-c", `"${hookPath}"`], {
374+
// Source bashrc before running init hook so it has access to user's environment
375+
const commandWithBashrc = `${getMuxBashrcSourceSnippet()}\n"${hookPath}"`;
376+
const proc = spawn(bashPath, ["-c", commandWithBashrc], {
370377
cwd: workspacePath,
371378
stdio: ["ignore", "pipe", "pipe"],
372379
env: {

src/node/runtime/SSHRuntime.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { getErrorMessage } from "@/common/utils/errors";
2626
import { execAsync, DisposableProcess } from "@/node/utils/disposableExec";
2727
import { getControlPath } from "./sshConnectionPool";
2828
import { getBashPath } from "@/node/utils/main/bashPath";
29+
import { getMuxBashrcSourceSnippet } from "@/common/constants/paths";
2930

3031
/**
3132
* Shell-escape helper for remote bash.
@@ -107,6 +108,10 @@ export class SSHRuntime implements Runtime {
107108
// Add cd command if cwd is specified
108109
parts.push(cdCommandForSSH(options.cwd));
109110

111+
// Source ~/.mux/bashrc if it exists (sets up PATH, nix, direnv, etc.)
112+
// This is necessary because `bash -c` doesn't source ~/.bashrc
113+
parts.push(getMuxBashrcSourceSnippet());
114+
110115
// Add environment variable exports (user env first, then non-interactive overrides)
111116
const envVars = { ...options.env, ...NON_INTERACTIVE_ENV_VARS };
112117
for (const [key, value] of Object.entries(envVars)) {

src/node/runtime/WorktreeRuntime.test.ts

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { describe, expect, it } from "bun:test";
1+
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
22
import * as os from "os";
33
import * as path from "path";
4+
import * as fs from "fs/promises";
45
import { WorktreeRuntime } from "./WorktreeRuntime";
56

67
describe("WorktreeRuntime constructor", () => {
@@ -65,3 +66,82 @@ describe("WorktreeRuntime.resolvePath", () => {
6566
expect(path.isAbsolute(resolved)).toBe(true);
6667
});
6768
});
69+
70+
describe("WorktreeRuntime.exec bashrc sourcing", () => {
71+
// Use a temp directory as fake HOME with .mux/bashrc inside
72+
const testHome = path.join(os.tmpdir(), `mux-bashrc-test-${Date.now()}`);
73+
const testMuxDir = path.join(testHome, ".mux");
74+
const testBashrcPath = path.join(testMuxDir, "bashrc");
75+
const testWorkDir = path.join(os.tmpdir(), "mux-bashrc-workdir");
76+
77+
beforeEach(async () => {
78+
// Create test directories
79+
await fs.mkdir(testMuxDir, { recursive: true });
80+
await fs.mkdir(testWorkDir, { recursive: true });
81+
});
82+
83+
afterEach(async () => {
84+
// Clean up test files
85+
await fs.rm(testHome, { recursive: true, force: true });
86+
await fs.rm(testWorkDir, { recursive: true, force: true });
87+
});
88+
89+
it("should source ~/.mux/bashrc and set environment variables", async () => {
90+
// Create a test bashrc that sets a unique environment variable
91+
const testEnvValue = `test_value_${Date.now()}`;
92+
await fs.writeFile(testBashrcPath, `export MUX_TEST_BASHRC_VAR="${testEnvValue}"\n`);
93+
94+
const runtime = new WorktreeRuntime("/tmp");
95+
// Pass HOME as environment variable so the child process uses our test home
96+
const stream = await runtime.exec("echo $MUX_TEST_BASHRC_VAR", {
97+
cwd: testWorkDir,
98+
timeout: 5,
99+
env: { HOME: testHome },
100+
});
101+
102+
// Read stdout
103+
const reader = stream.stdout.getReader();
104+
const decoder = new TextDecoder();
105+
let output = "";
106+
while (true) {
107+
const { done, value } = await reader.read();
108+
if (done) break;
109+
output += decoder.decode(value, { stream: true });
110+
}
111+
output += decoder.decode();
112+
113+
await stream.exitCode;
114+
115+
// Should have the value set by our bashrc
116+
expect(output.trim()).toBe(testEnvValue);
117+
});
118+
119+
it("should work silently when ~/.mux/bashrc doesn't exist", async () => {
120+
// Don't create bashrc - just have empty .mux dir
121+
await fs.rm(testBashrcPath, { force: true });
122+
123+
const runtime = new WorktreeRuntime("/tmp");
124+
const stream = await runtime.exec("echo hello", {
125+
cwd: testWorkDir,
126+
timeout: 5,
127+
env: { HOME: testHome },
128+
});
129+
130+
// Read stdout
131+
const reader = stream.stdout.getReader();
132+
const decoder = new TextDecoder();
133+
let output = "";
134+
while (true) {
135+
const { done, value } = await reader.read();
136+
if (done) break;
137+
output += decoder.decode(value, { stream: true });
138+
}
139+
output += decoder.decode();
140+
141+
const exitCode = await stream.exitCode;
142+
143+
// Command should succeed with expected output
144+
expect(exitCode).toBe(0);
145+
expect(output.trim()).toBe("hello");
146+
});
147+
});

0 commit comments

Comments
 (0)