Skip to content

Commit 60378e2

Browse files
committed
Add shell activation support for automatic environment setup
This commit introduces comprehensive shell activation functionality that allows mvx to automatically set up development environments when entering directories with .mvx configuration. Key features: - Shell integration for bash, zsh, fish, and PowerShell - Automatic PATH management with mvx-managed tools - Environment variable setup from configuration - Directory change detection for efficient activation - Deactivation support for all shells - Bootstrap script detection with fallback to global mvx New commands: - 'mvx activate <shell>' - Generate shell integration code - 'mvx deactivate' - Provide deactivation instructions - 'mvx env' - Output environment variables for shell integration The activation system works by: 1. Installing shell hooks that detect directory changes 2. Looking for .mvx directories in current/parent directories 3. Running 'mvx env' to get tool paths and environment variables 4. Evaluating the output to update the shell environment This provides a seamless development experience similar to tools like mise, direnv, and asdf, where tools become automatically available when entering project directories. Documentation includes: - Updated README with shell activation quick start - Comprehensive shell activation guide on website - Examples for all supported shells - Integration with existing mvx workflow All tests pass and the implementation follows mvx patterns for tool management and configuration handling.
1 parent a68a4be commit 60378e2

File tree

13 files changed

+2307
-0
lines changed

13 files changed

+2307
-0
lines changed

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,37 @@ cd mvx
200200
./mvx test
201201
```
202202

203+
## 🔄 Shell Activation
204+
205+
For a seamless development experience, enable shell activation to automatically set up your environment when entering project directories:
206+
207+
**Bash** - Add to `~/.bashrc`:
208+
```bash
209+
eval "$(mvx activate bash)"
210+
```
211+
212+
**Zsh** - Add to `~/.zshrc`:
213+
```bash
214+
eval "$(mvx activate zsh)"
215+
```
216+
217+
**Fish** - Add to `~/.config/fish/config.fish`:
218+
```bash
219+
mvx activate fish | source
220+
```
221+
222+
With shell activation enabled, tools become available automatically:
223+
224+
```bash
225+
cd my-project
226+
# mvx: activating environment in /Users/you/my-project
227+
228+
java -version # Uses mvx-managed Java
229+
mvn -version # Uses mvx-managed Maven
230+
```
231+
232+
**Learn more**: See the [Shell Activation Guide](https://gnodet.github.io/mvx/shell-activation/) for detailed documentation.
233+
203234
## 🎯 Shell Completion
204235

205236
mvx supports shell completion for commands and arguments across multiple shells (bash, zsh, fish, powershell):

cmd/activate.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
8+
"github.com/gnodet/mvx/pkg/shell"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
// activateCmd represents the activate command
13+
var activateCmd = &cobra.Command{
14+
Use: "activate [shell]",
15+
Short: "Generate shell integration code for automatic environment activation",
16+
Long: `Generate shell integration code that automatically activates mvx when entering
17+
directories with .mvx configuration.
18+
19+
This command outputs shell-specific code that should be evaluated in your shell's
20+
configuration file (e.g., ~/.bashrc, ~/.zshrc, ~/.config/fish/config.fish).
21+
22+
When activated, mvx will:
23+
- Detect when you enter a directory with .mvx configuration
24+
- Automatically update PATH with mvx-managed tools
25+
- Set up environment variables from your configuration
26+
- Cache the environment to avoid repeated setup
27+
28+
Supported shells:
29+
- bash
30+
- zsh
31+
- fish
32+
- powershell
33+
34+
Examples:
35+
# Bash - add to ~/.bashrc
36+
eval "$(mvx activate bash)"
37+
38+
# Zsh - add to ~/.zshrc
39+
eval "$(mvx activate zsh)"
40+
41+
# Fish - add to ~/.config/fish/config.fish
42+
mvx activate fish | source
43+
44+
# PowerShell - add to $PROFILE
45+
Invoke-Expression (mvx activate powershell | Out-String)
46+
47+
After adding to your shell configuration, restart your shell or source the file:
48+
source ~/.bashrc # bash
49+
source ~/.zshrc # zsh
50+
source ~/.config/fish/config.fish # fish
51+
52+
Configuration:
53+
MVX_VERBOSE=true # Enable verbose output to see what mvx is doing`,
54+
55+
Args: cobra.ExactArgs(1),
56+
Run: func(cmd *cobra.Command, args []string) {
57+
shellType := args[0]
58+
59+
// Validate shell type
60+
validShells := []string{"bash", "zsh", "fish", "powershell"}
61+
isValid := false
62+
for _, s := range validShells {
63+
if s == shellType {
64+
isValid = true
65+
break
66+
}
67+
}
68+
69+
if !isValid {
70+
printError("Unsupported shell: %s", shellType)
71+
printError("Supported shells: bash, zsh, fish, powershell")
72+
os.Exit(1)
73+
}
74+
75+
// Get the path to the mvx binary
76+
mvxPath, err := getMvxBinaryPath()
77+
if err != nil {
78+
printError("Failed to determine mvx binary path: %v", err)
79+
os.Exit(1)
80+
}
81+
82+
// Generate shell hook
83+
hook, err := shell.GenerateHook(shellType, mvxPath)
84+
if err != nil {
85+
printError("Failed to generate shell hook: %v", err)
86+
os.Exit(1)
87+
}
88+
89+
// Output the hook (this will be evaluated by the shell)
90+
fmt.Print(hook)
91+
},
92+
}
93+
94+
// deactivateCmd represents the deactivate command
95+
var deactivateCmd = &cobra.Command{
96+
Use: "deactivate",
97+
Short: "Deactivate mvx shell integration",
98+
Long: `Deactivate mvx shell integration for the current shell session.
99+
100+
This command is automatically available after running 'mvx activate' and
101+
removes mvx-managed tools from PATH and unsets mvx environment variables.
102+
103+
Note: This only affects the current shell session. To permanently disable
104+
mvx activation, remove the 'eval "$(mvx activate ...)"' line from your
105+
shell configuration file.
106+
107+
Examples:
108+
mvx deactivate # Remove mvx from current session`,
109+
110+
Run: func(cmd *cobra.Command, args []string) {
111+
// This command is primarily handled by the shell hook itself
112+
// When called directly, we just provide information
113+
printInfo("To deactivate mvx in your current shell session:")
114+
printInfo("")
115+
printInfo(" Bash/Zsh:")
116+
printInfo(" Run: mvx_deactivate")
117+
printInfo("")
118+
printInfo(" Fish:")
119+
printInfo(" Run: mvx_deactivate")
120+
printInfo("")
121+
printInfo(" PowerShell:")
122+
printInfo(" Run: mvx-deactivate")
123+
printInfo("")
124+
printInfo("To permanently disable mvx activation, remove the activation")
125+
printInfo("line from your shell configuration file:")
126+
printInfo(" - Bash: ~/.bashrc")
127+
printInfo(" - Zsh: ~/.zshrc")
128+
printInfo(" - Fish: ~/.config/fish/config.fish")
129+
printInfo(" - PowerShell: $PROFILE")
130+
},
131+
}
132+
133+
// getMvxBinaryPath returns the path to the mvx binary
134+
func getMvxBinaryPath() (string, error) {
135+
// Try to get the path from the current executable
136+
exePath, err := os.Executable()
137+
if err != nil {
138+
return "", fmt.Errorf("failed to get executable path: %w", err)
139+
}
140+
141+
// Resolve symlinks
142+
exePath, err = filepath.EvalSymlinks(exePath)
143+
if err != nil {
144+
return "", fmt.Errorf("failed to resolve symlinks: %w", err)
145+
}
146+
147+
return exePath, nil
148+
}

cmd/activate_test.go

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
package cmd
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
9+
"github.com/gnodet/mvx/pkg/shell"
10+
)
11+
12+
func TestGetMvxBinaryPath(t *testing.T) {
13+
path, err := getMvxBinaryPath()
14+
if err != nil {
15+
t.Fatalf("getMvxBinaryPath() failed: %v", err)
16+
}
17+
18+
if path == "" {
19+
t.Error("getMvxBinaryPath() returned empty path")
20+
}
21+
22+
// Check that the path exists
23+
if _, err := os.Stat(path); os.IsNotExist(err) {
24+
t.Errorf("getMvxBinaryPath() returned non-existent path: %s", path)
25+
}
26+
27+
t.Logf("mvx binary path: %s", path)
28+
}
29+
30+
func TestActivateCommand(t *testing.T) {
31+
// Save original args
32+
originalArgs := os.Args
33+
defer func() { os.Args = originalArgs }()
34+
35+
tests := []struct {
36+
name string
37+
shell string
38+
expectedInHook []string
39+
shouldError bool
40+
}{
41+
{
42+
name: "bash activation",
43+
shell: "bash",
44+
expectedInHook: []string{
45+
"# mvx shell integration for bash",
46+
"_mvx_hook()",
47+
"PROMPT_COMMAND",
48+
"mvx_deactivate()",
49+
".mvx",
50+
},
51+
shouldError: false,
52+
},
53+
{
54+
name: "zsh activation",
55+
shell: "zsh",
56+
expectedInHook: []string{
57+
"# mvx shell integration for zsh",
58+
"_mvx_hook()",
59+
"precmd",
60+
"mvx_deactivate()",
61+
".mvx",
62+
},
63+
shouldError: false,
64+
},
65+
{
66+
name: "fish activation",
67+
shell: "fish",
68+
expectedInHook: []string{
69+
"# mvx shell integration for fish",
70+
"function _mvx_hook",
71+
"--on-variable PWD",
72+
"mvx_deactivate",
73+
".mvx",
74+
},
75+
shouldError: false,
76+
},
77+
{
78+
name: "powershell activation",
79+
shell: "powershell",
80+
expectedInHook: []string{
81+
"# mvx shell integration for PowerShell",
82+
"function global:_mvx_hook",
83+
"prompt",
84+
"mvx-deactivate",
85+
".mvx",
86+
},
87+
shouldError: false,
88+
},
89+
}
90+
91+
for _, tt := range tests {
92+
t.Run(tt.name, func(t *testing.T) {
93+
// Capture stdout
94+
oldStdout := os.Stdout
95+
r, w, _ := os.Pipe()
96+
os.Stdout = w
97+
98+
// Set up command args
99+
os.Args = []string{"mvx", "activate", tt.shell}
100+
101+
// Execute activate command
102+
activateCmd.Run(activateCmd, []string{tt.shell})
103+
104+
// Restore stdout
105+
w.Close()
106+
os.Stdout = oldStdout
107+
108+
// Read captured output
109+
buf := make([]byte, 10000)
110+
n, _ := r.Read(buf)
111+
output := string(buf[:n])
112+
113+
// Check for expected content
114+
for _, expected := range tt.expectedInHook {
115+
if !strings.Contains(output, expected) {
116+
t.Errorf("Expected hook to contain '%s', but it didn't.\nOutput:\n%s", expected, output)
117+
}
118+
}
119+
120+
// Check that the hook contains the mvx binary path
121+
mvxPath, _ := getMvxBinaryPath()
122+
if !strings.Contains(output, mvxPath) {
123+
t.Errorf("Expected hook to contain mvx binary path '%s', but it didn't.\nOutput:\n%s", mvxPath, output)
124+
}
125+
126+
t.Logf("Generated hook for %s:\n%s", tt.shell, output)
127+
})
128+
}
129+
}
130+
131+
func TestActivateCommandUnsupportedShell(t *testing.T) {
132+
// Test that GenerateHook returns error for unsupported shell
133+
_, err := shell.GenerateHook("unsupported-shell", "/usr/local/bin/mvx")
134+
if err == nil {
135+
t.Error("Expected error for unsupported shell, got nil")
136+
}
137+
138+
if !strings.Contains(err.Error(), "unsupported shell") {
139+
t.Errorf("Expected error message about unsupported shell, got: %v", err)
140+
}
141+
}
142+
143+
func TestDeactivateCommand(t *testing.T) {
144+
// Capture stdout
145+
oldStdout := os.Stdout
146+
r, w, _ := os.Pipe()
147+
os.Stdout = w
148+
149+
// Execute deactivate command
150+
deactivateCmd.Run(deactivateCmd, []string{})
151+
152+
// Restore stdout
153+
w.Close()
154+
os.Stdout = oldStdout
155+
156+
// Read captured output
157+
buf := make([]byte, 2000)
158+
n, _ := r.Read(buf)
159+
output := string(buf[:n])
160+
161+
// Check for expected content
162+
expectedStrings := []string{
163+
"deactivate mvx",
164+
"Bash/Zsh",
165+
"Fish",
166+
"PowerShell",
167+
"mvx_deactivate",
168+
"mvx-deactivate",
169+
}
170+
171+
for _, expected := range expectedStrings {
172+
if !strings.Contains(output, expected) {
173+
t.Errorf("Expected deactivate output to contain '%s', but it didn't.\nOutput:\n%s", expected, output)
174+
}
175+
}
176+
177+
t.Logf("Deactivate command output:\n%s", output)
178+
}
179+
180+
func TestActivateCommandIntegration(t *testing.T) {
181+
if testing.Short() {
182+
t.Skip("Skipping integration test in short mode")
183+
}
184+
185+
// Build a test binary
186+
tempDir := t.TempDir()
187+
mvxBinary := filepath.Join(tempDir, "mvx-test")
188+
189+
// Note: This assumes we're running from the project root
190+
// In a real test environment, you might need to adjust the path
191+
t.Logf("Building test binary at %s", mvxBinary)
192+
193+
// For now, we'll skip the actual build and just test the command structure
194+
// A full integration test would build the binary and test the actual shell hooks
195+
t.Skip("Full integration test requires building binary - implement if needed")
196+
}

0 commit comments

Comments
 (0)