Skip to content

Commit a09a85f

Browse files
committed
feat: enable cross-platform directory persistence and automated shell integration (v1.1.1)
1 parent 061f233 commit a09a85f

9 files changed

Lines changed: 319 additions & 9 deletions

File tree

README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,25 @@ hey <your objective in plain English>
108108
| `npm run build 2>&1 \| hey what broke?` | Reads piped stderr and explains the error |
109109
| `hey --clear` | Wipes conversational memory |
110110

111-
### Execution Levels
111+
---
112+
113+
## Shell Integration (Recommended)
114+
115+
By default, CLI tools cannot change your terminal's directory because they run in a subshell. To enable `hey` to change your directory (e.g., `hey go to desktop`), add the following to your shell configuration (`~/.zshrc` or `~/.bashrc`):
116+
117+
```bash
118+
eval "$(hey --shell-init)"
119+
```
120+
121+
**For Windows (PowerShell):**
122+
123+
```powershell
124+
hey --shell-init | Out-String | iex
125+
```
126+
127+
---
128+
129+
## Execution Levels
112130

113131
| Level | Flag | Behavior |
114132
| ----- | ----------- | -------------------------------------------------------------------- |

hey_cli/cli.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ def main():
6868
parser.add_argument(
6969
"--check-cache", type=str, help="Check local cache for instant fix"
7070
)
71+
parser.add_argument(
72+
"--shell-init", action="store_true", help="Output shell function for directory persistence"
73+
)
7174

7275
args = parser.parse_args()
7376

@@ -95,6 +98,36 @@ def main():
9598
if args.check_cache:
9699
sys.exit(0)
97100

101+
if args.shell_init:
102+
is_windows = os.name == "nt"
103+
if is_windows:
104+
shell_func = r"""
105+
function hey {
106+
& hey.exe @args
107+
$handoff = Join-Path $HOME ".hey_cwd_handoff"
108+
if (Test-Path $handoff) {
109+
$target = Get-Content $handoff -Raw
110+
Remove-Item $handoff
111+
if (Test-Path $target.Trim()) {
112+
Set-Location $target.Trim()
113+
}
114+
}
115+
}
116+
"""
117+
else:
118+
shell_func = r"""
119+
hey() {
120+
command hey "$@"
121+
if [ -f "$HOME/.hey_cwd_handoff" ]; then
122+
local target=$(cat "$HOME/.hey_cwd_handoff")
123+
rm -f "$HOME/.hey_cwd_handoff"
124+
[ -d "$target" ] && cd "$target"
125+
fi
126+
}
127+
"""
128+
print(shell_func.strip())
129+
sys.exit(0)
130+
98131
# Only check Ollama when we're about to call the LLM
99132
check_ollama()
100133

hey_cli/governance.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
"kubectl delete"
2222
],
2323
"allowed": [
24-
"ls", "cat", "pwd", "grep", "find", "echo", "tail"
24+
"ls", "cat", "pwd", "grep", "find", "echo", "tail", "cd"
2525
],
2626
"high_risk_keywords": [
2727
"reset", "delete", "drop", "truncate", "prune", "rm", "-exec", ">"

hey_cli/runner.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
import sys
33
import json
44
import dataclasses
5+
import os
6+
import platform
7+
import re
58
from typing import Optional
69

710
from .governance import GovernanceEngine, Action
@@ -17,17 +20,44 @@ def __init__(self, governance: GovernanceEngine, level: int = 1, model_name: str
1720
self.history_mgr = history_mgr
1821
self.console = Console()
1922

20-
def run_command(self, cmd: str) -> tuple[int, str]:
23+
def run_command(self, cmd: str, capture_pwd: bool = False) -> tuple[int, str]:
2124
"""Executes a command and returns exit code and combined output."""
25+
is_windows = platform.system() == "Windows"
2226
try:
27+
full_cmd = cmd
28+
if capture_pwd:
29+
if is_windows:
30+
# Windows CMD syntax for capturing PWD
31+
full_cmd = f'("{cmd}") & echo. & echo HEY_CWD_HANDOFF:%CD%'
32+
else:
33+
# Unix shell syntax
34+
full_cmd = f'{{ {cmd} ; }} ; printf "\\nHEY_CWD_HANDOFF:%s\\n" "$(pwd)"'
35+
2336
result = subprocess.run(
24-
cmd,
37+
full_cmd,
2538
shell=True,
2639
stdout=subprocess.PIPE,
2740
stderr=subprocess.STDOUT,
2841
text=True
2942
)
30-
return result.returncode, result.stdout
43+
44+
out = result.stdout
45+
if capture_pwd and "HEY_CWD_HANDOFF:" in out:
46+
match = re.search(r"HEY_CWD_HANDOFF:(.*)", out)
47+
if match:
48+
cwd = match.group(1).strip()
49+
# Clean up output to hide the marker and the extra newline
50+
out = re.sub(r"\n?HEY_CWD_HANDOFF:.*", "", out, flags=re.DOTALL).strip()
51+
52+
# Normalize paths for comparison (especially on Windows)
53+
norm_cwd = os.path.normpath(cwd).lower() if is_windows else os.path.normpath(cwd)
54+
norm_actual = os.path.normpath(os.getcwd()).lower() if is_windows else os.path.normpath(os.getcwd())
55+
56+
if norm_cwd != norm_actual:
57+
with open(os.path.expanduser("~/.hey_cwd_handoff"), "w") as f:
58+
f.write(cwd)
59+
60+
return result.returncode, out
3161
except Exception as e:
3262
return -1, str(e)
3363

@@ -146,7 +176,7 @@ def execute_flow(self, initial_response: CommandResponse, original_objective: st
146176
if self.level in (1, 2):
147177
if self._check_governance(cmd):
148178
self.console.print(f"[bold green]● Running:[/bold green] {cmd}")
149-
code, out = self.run_command(cmd)
179+
code, out = self.run_command(cmd, capture_pwd=True)
150180
if out.strip():
151181
print(out.strip())
152182
sys.exit(code)

install.ps1

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,4 +123,35 @@ Write-Host "============ SUCCESS =============" -ForegroundColor Green
123123
Write-Host "hey-cli is successfully installed!"
124124
Write-Host "You may need to restart your PowerShell window for the 'hey' command to be recognized."
125125
Write-Host "Test it out by typing: " -NoNewline
126-
Write-Host "hey is docker running" -ForegroundColor Cyan
126+
Write-Host "hey hi" -ForegroundColor Cyan
127+
Write-Host ""
128+
129+
# 7. Automated Shell Integration
130+
if ($null -ne $PROFILE) {
131+
Write-Host "Would you like to enable directory persistence (making 'cd' work)? [y/N]" -ForegroundColor Yellow
132+
$confirm = Read-Host "> "
133+
if ($confirm -match "^[Yy]$") {
134+
# Ensure profile directory exists
135+
$profileDir = Split-Path -Path $PROFILE
136+
if (!(Test-Path $profileDir)) {
137+
New-Item -ItemType Directory -Path $profileDir -Force | Out-Null
138+
}
139+
# Ensure profile file exists
140+
if (!(Test-Path $PROFILE)) {
141+
New-Item -ItemType File -Path $PROFILE -Force | Out-Null
142+
}
143+
144+
$initLine = "hey --shell-init | Out-String | iex"
145+
if (!(Select-String -Path $PROFILE -Pattern "hey --shell-init" -Quiet)) {
146+
Add-Content -Path $PROFILE -Value "`n# hey-cli shell integration`n$initLine"
147+
Write-Host "[OK] Added shell integration to $PROFILE. Please restart PowerShell." -ForegroundColor Green
148+
} else {
149+
Write-Host "[OK] Shell integration already exists in $PROFILE." -ForegroundColor Green
150+
}
151+
} else {
152+
Write-Host "Skipping shell integration. You can always add it later manually." -ForegroundColor Gray
153+
}
154+
} else {
155+
Write-Host "Tip: To enable 'cd' persistence, add this to your PowerShell profile:" -ForegroundColor Yellow
156+
Write-Host "hey --shell-init | Out-String | iex" -ForegroundColor Cyan
157+
}

install.sh

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ set -e
66
RED='\033[0;31m'
77
GREEN='\033[0;32m'
88
BLUE='\033[0;34m'
9+
YELLOW='\033[1;33m'
910
NC='\033[0m' # No Color
1011

1112
# 0. Detect Architecture for macOS
@@ -109,6 +110,36 @@ fi
109110
echo -e "\n${GREEN}============ SUCCESS =============${NC}"
110111
echo -e "hey-cli is successfully installed!"
111112
echo -e "You may need to restart your terminal or run ${BLUE}source ~/.bashrc${NC} (or .zshrc) for the 'hey' command to be recognized."
112-
echo -e "Test it out by typing: ${BLUE}hey hi${NC}"
113+
echo -e "Test it out by typing: ${BLUE}hey hi${NC}\n"
114+
115+
# 7. Automated Shell Integration
116+
SHELL_TYPE=$(basename "$SHELL")
117+
PROFILE=""
118+
case "$SHELL_TYPE" in
119+
zsh) PROFILE="$HOME/.zshrc" ;;
120+
bash) PROFILE="$HOME/.bashrc" ;;
121+
*) PROFILE="" ;;
122+
esac
123+
124+
if [ -n "$PROFILE" ]; then
125+
echo -e "${YELLOW}Would you like to enable directory persistence (making 'cd' work)? [y/N]${NC}"
126+
# Use read with a timeout or just skip if non-interactive
127+
if [[ -t 0 ]]; then
128+
read -r -p "> " CONFIRM
129+
if [[ "$CONFIRM" =~ ^[Yy]$ ]]; then
130+
if grep -q "hey --shell-init" "$PROFILE" 2>/dev/null; then
131+
echo -e "✔️ Shell integration already exists in $PROFILE"
132+
else
133+
echo -e "\n# hey-cli shell integration\neval \"\$(hey --shell-init)\"" >> "$PROFILE"
134+
echo -e "${GREEN}✔️ Added shell integration to $PROFILE. Please restart your terminal or run: source $PROFILE${NC}"
135+
fi
136+
else
137+
echo -e "${BLUE}Skipping shell integration. You can always add it later manually.${NC}"
138+
fi
139+
fi
140+
else
141+
echo -e "${YELLOW}Tip: To enable 'cd' persistence, add this to your shell profile:${NC}"
142+
echo -e "${BLUE}eval \"\$(hey --shell-init)\"${NC}"
143+
fi
113144

114145
} # End of script block

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "hey-cli-python"
7-
version = "1.1.0"
7+
version = "1.1.1"
88
description = "A secure, zero-bloat CLI companion that turns natural language and error logs into executable commands."
99
readme = "README.md"
1010
requires-python = ">=3.9"

tests/test_runner.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import unittest
2+
from unittest.mock import patch, MagicMock
3+
import os
4+
import tempfile
5+
from hey_cli.runner import CommandRunner
6+
from hey_cli.governance import GovernanceEngine
7+
8+
class TestCommandRunnerHandoff(unittest.TestCase):
9+
def setUp(self):
10+
self.gov = MagicMock(spec=GovernanceEngine)
11+
self.runner = CommandRunner(governance=self.gov)
12+
self.handoff_path = os.path.expanduser("~/.hey_cwd_handoff")
13+
if os.path.exists(self.handoff_path):
14+
os.remove(self.handoff_path)
15+
16+
def tearDown(self):
17+
if os.path.exists(self.handoff_path):
18+
os.remove(self.handoff_path)
19+
20+
@patch("subprocess.run")
21+
def test_run_command_captures_pwd(self, mock_run):
22+
# Simulate a command that includes the HEY_CWD_HANDOFF marker
23+
mock_result = MagicMock()
24+
mock_result.returncode = 0
25+
# Mocking the output of '(cd /tmp) ; printf "\nHEY_CWD_HANDOFF:%s\n" "$(pwd)"'
26+
mock_result.stdout = "some output\nHEY_CWD_HANDOFF:/tmp\n"
27+
mock_run.return_value = mock_result
28+
29+
code, out = self.runner.run_command("cd /tmp", capture_pwd=True)
30+
31+
# Verify the handoff file was created with the correct path
32+
self.assertTrue(os.path.exists(self.handoff_path))
33+
with open(self.handoff_path, "r") as f:
34+
self.assertEqual(f.read().strip(), "/tmp")
35+
36+
# Verify the marker was stripped from the output
37+
self.assertEqual(out.strip(), "some output")
38+
39+
# Verify the command was wrapped correctly
40+
mock_run.assert_called_once()
41+
called_cmd = mock_run.call_args[0][0]
42+
self.assertIn("HEY_CWD_HANDOFF", called_cmd)
43+
44+
@patch("subprocess.run")
45+
def test_run_command_no_capture_pwd(self, mock_run):
46+
mock_result = MagicMock()
47+
mock_result.returncode = 0
48+
mock_result.stdout = "normal output\n"
49+
mock_run.return_value = mock_result
50+
51+
code, out = self.runner.run_command("ls", capture_pwd=False)
52+
53+
self.assertFalse(os.path.exists(self.handoff_path))
54+
self.assertEqual(out.strip(), "normal output")
55+
56+
# Verify the command was NOT wrapped
57+
mock_run.assert_called_once_with(
58+
"ls", shell=True, stdout=-1, stderr=-2, text=True
59+
)
60+
61+
@patch("platform.system")
62+
@patch("subprocess.run")
63+
def test_run_command_windows_handoff(self, mock_run, mock_platform):
64+
mock_platform.return_value = "Windows"
65+
66+
mock_result = MagicMock()
67+
mock_result.returncode = 0
68+
mock_result.stdout = "some output\nHEY_CWD_HANDOFF:C:\\Temp\n"
69+
mock_run.return_value = mock_result
70+
71+
# We need to mock os.getcwd and os.path.normpath to match Windows style in this test
72+
with patch("os.getcwd", return_value="C:\\Users\\Test"), \
73+
patch("os.path.expanduser", return_value=self.handoff_path):
74+
75+
code, out = self.runner.run_command("cd C:\\Temp", capture_pwd=True)
76+
77+
# Verify the command was wrapped using Windows CMD syntax
78+
called_cmd = mock_run.call_args[0][0]
79+
self.assertEqual(called_cmd, '("cd C:\\Temp") & echo. & echo HEY_CWD_HANDOFF:%CD%')
80+
81+
# Verify the handoff file was created
82+
self.assertTrue(os.path.exists(self.handoff_path))
83+
with open(self.handoff_path, "r") as f:
84+
self.assertEqual(f.read().strip(), "C:\\Temp")
85+
86+
if __name__ == "__main__":
87+
unittest.main()

0 commit comments

Comments
 (0)