feat: Set and Forget - Seamless Gas Town Integration (#255)

Adds shell integration for automatic Gas Town context detection.

Features:
- `gt enable` / `gt disable` - Global on/off switch
- `gt shell install|remove|status` - Shell integration management
- `gt rig quick-add [path]` - One-command project setup
- `gt uninstall` - Clean removal with options
- Shell hook auto-sets GT_TOWN_ROOT/GT_RIG on cd

Implementation:
- XDG-compliant state storage (~/.local/state/gastown/)
- Safe RC file manipulation with block markers
- Environment overrides (GASTOWN_DISABLED/ENABLED)
- Doctor check for global state validation

Co-authored-by: Sohail Mohammad <sohailm25@gmail.com>
This commit is contained in:
Sohail Mohammad
2026-01-08 22:25:01 -06:00
committed by GitHub
parent 41a758d6d8
commit 81bfe48ed3
21 changed files with 1751 additions and 11 deletions
+299
View File
@@ -0,0 +1,299 @@
// ABOUTME: Shell integration installation and removal for Gas Town.
// ABOUTME: Manages the shell hook in RC files with safe block markers.
package shell
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/steveyegge/gastown/internal/state"
)
const (
markerStart = "# --- Gas Town Integration (managed by gt) ---"
markerEnd = "# --- End Gas Town ---"
)
func hookSourceLine() string {
return fmt.Sprintf(`[[ -f "%s/shell-hook.sh" ]] && source "%s/shell-hook.sh"`,
state.ConfigDir(), state.ConfigDir())
}
func Install() error {
shell := DetectShell()
rcPath := RCFilePath(shell)
if err := writeHookScript(); err != nil {
return fmt.Errorf("writing hook script: %w", err)
}
if err := addToRCFile(rcPath); err != nil {
return fmt.Errorf("updating %s: %w", rcPath, err)
}
return state.SetShellIntegration(shell)
}
func Remove() error {
shell := DetectShell()
rcPath := RCFilePath(shell)
if err := removeFromRCFile(rcPath); err != nil {
return fmt.Errorf("updating %s: %w", rcPath, err)
}
hookPath := filepath.Join(state.ConfigDir(), "shell-hook.sh")
os.Remove(hookPath)
return nil
}
func DetectShell() string {
shell := os.Getenv("SHELL")
if strings.HasSuffix(shell, "zsh") {
return "zsh"
}
if strings.HasSuffix(shell, "bash") {
return "bash"
}
return "zsh"
}
func RCFilePath(shell string) string {
home, _ := os.UserHomeDir()
switch shell {
case "bash":
return filepath.Join(home, ".bashrc")
default:
return filepath.Join(home, ".zshrc")
}
}
func writeHookScript() error {
dir := state.ConfigDir()
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
hookPath := filepath.Join(dir, "shell-hook.sh")
return os.WriteFile(hookPath, []byte(shellHookScript), 0644)
}
func addToRCFile(path string) error {
data, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return err
}
content := string(data)
if strings.Contains(content, markerStart) {
return updateRCFile(path, content)
}
block := fmt.Sprintf("\n%s\n%s\n%s\n", markerStart, hookSourceLine(), markerEnd)
if len(data) > 0 {
backupPath := path + ".gastown-backup"
os.WriteFile(backupPath, data, 0644)
}
return os.WriteFile(path, []byte(content+block), 0644)
}
func removeFromRCFile(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return nil
}
content := string(data)
startIdx := strings.Index(content, markerStart)
if startIdx == -1 {
return nil
}
endIdx := strings.Index(content[startIdx:], markerEnd)
if endIdx == -1 {
return nil
}
endIdx += startIdx + len(markerEnd)
if endIdx < len(content) && content[endIdx] == '\n' {
endIdx++
}
if startIdx > 0 && content[startIdx-1] == '\n' {
startIdx--
}
newContent := content[:startIdx] + content[endIdx:]
return os.WriteFile(path, []byte(newContent), 0644)
}
func updateRCFile(path, content string) error {
startIdx := strings.Index(content, markerStart)
endIdx := strings.Index(content[startIdx:], markerEnd)
if endIdx == -1 {
return fmt.Errorf("malformed Gas Town block in %s", path)
}
endIdx += startIdx + len(markerEnd)
block := fmt.Sprintf("%s\n%s\n%s", markerStart, hookSourceLine(), markerEnd)
newContent := content[:startIdx] + block + content[endIdx:]
return os.WriteFile(path, []byte(newContent), 0644)
}
var shellHookScript = `#!/bin/bash
# Gas Town Shell Integration
# Installed by: gt install --shell
# Location: ~/.config/gastown/shell-hook.sh
_gastown_enabled() {
[[ -n "$GASTOWN_DISABLED" ]] && return 1
[[ -n "$GASTOWN_ENABLED" ]] && return 0
local state_file="$HOME/.local/state/gastown/state.json"
[[ -f "$state_file" ]] && grep -q '"enabled":\s*true' "$state_file" 2>/dev/null
}
_gastown_ignored() {
local dir="$PWD"
while [[ "$dir" != "/" ]]; do
[[ -f "$dir/.gastown-ignore" ]] && return 0
dir="$(dirname "$dir")"
done
return 1
}
_gastown_already_asked() {
local repo_root="$1"
local asked_file="$HOME/.cache/gastown/asked-repos"
[[ -f "$asked_file" ]] && grep -qF "$repo_root" "$asked_file" 2>/dev/null
}
_gastown_mark_asked() {
local repo_root="$1"
local asked_file="$HOME/.cache/gastown/asked-repos"
mkdir -p "$(dirname "$asked_file")"
echo "$repo_root" >> "$asked_file"
}
_gastown_offer_add() {
local repo_root="$1"
_gastown_already_asked "$repo_root" && return 0
[[ -t 0 ]] || return 0
local repo_name
repo_name=$(basename "$repo_root")
echo ""
echo -n "Add '$repo_name' to Gas Town? [y/N/never] "
read -r response </dev/tty
_gastown_mark_asked "$repo_root"
case "$response" in
y|Y|yes)
echo "Adding to Gas Town..."
local output
output=$(gt rig quick-add "$repo_root" --yes 2>&1)
local exit_code=$?
echo "$output"
if [[ $exit_code -eq 0 ]]; then
local crew_path
crew_path=$(echo "$output" | grep "^GT_CREW_PATH=" | cut -d= -f2)
if [[ -n "$crew_path" && -d "$crew_path" ]]; then
echo ""
echo "Switching to crew workspace..."
cd "$crew_path" || true
# Re-run hook to set GT_TOWN_ROOT and GT_RIG
_gastown_hook
fi
fi
;;
never)
touch "$repo_root/.gastown-ignore"
echo "Created .gastown-ignore - won't ask again for this repo."
;;
*)
echo "Skipped. Run 'gt rig quick-add' later to add manually."
;;
esac
}
_gastown_hook() {
local previous_exit_status=$?
_gastown_enabled || {
unset GT_TOWN_ROOT GT_RIG
return $previous_exit_status
}
_gastown_ignored && {
unset GT_TOWN_ROOT GT_RIG
return $previous_exit_status
}
if ! git rev-parse --git-dir &>/dev/null; then
unset GT_TOWN_ROOT GT_RIG
return $previous_exit_status
fi
local repo_root
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || {
unset GT_TOWN_ROOT GT_RIG
return $previous_exit_status
}
local cache_file="$HOME/.cache/gastown/rigs.cache"
if [[ -f "$cache_file" ]]; then
local cached
cached=$(grep "^${repo_root}:" "$cache_file" 2>/dev/null)
if [[ -n "$cached" ]]; then
eval "${cached#*:}"
return $previous_exit_status
fi
fi
if command -v gt &>/dev/null; then
local detect_output
detect_output=$(gt rig detect "$repo_root" 2>/dev/null)
eval "$detect_output"
if [[ -n "$GT_TOWN_ROOT" ]]; then
(gt rig detect --cache "$repo_root" &>/dev/null &)
elif [[ -n "$_GASTOWN_OFFER_ADD" ]]; then
_gastown_offer_add "$repo_root"
unset _GASTOWN_OFFER_ADD
fi
fi
return $previous_exit_status
}
_gastown_chpwd_hook() {
_GASTOWN_OFFER_ADD=1
_gastown_hook
}
case "${SHELL##*/}" in
zsh)
autoload -Uz add-zsh-hook
add-zsh-hook chpwd _gastown_chpwd_hook
add-zsh-hook precmd _gastown_hook
;;
bash)
if [[ ";${PROMPT_COMMAND[*]:-};" != *";_gastown_hook;"* ]]; then
PROMPT_COMMAND="_gastown_chpwd_hook${PROMPT_COMMAND:+;$PROMPT_COMMAND}"
fi
;;
esac
_gastown_hook
`
+132
View File
@@ -0,0 +1,132 @@
// ABOUTME: Tests for shell integration install/remove functionality.
// ABOUTME: Verifies RC file manipulation and hook script creation.
package shell
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestDetectShell(t *testing.T) {
tests := []struct {
shellEnv string
want string
}{
{"/bin/zsh", "zsh"},
{"/usr/bin/zsh", "zsh"},
{"/bin/bash", "bash"},
{"/usr/bin/bash", "bash"},
{"", "zsh"},
}
for _, tt := range tests {
t.Run(tt.shellEnv, func(t *testing.T) {
orig := os.Getenv("SHELL")
defer os.Setenv("SHELL", orig)
os.Setenv("SHELL", tt.shellEnv)
got := DetectShell()
if got != tt.want {
t.Errorf("DetectShell() = %q, want %q", got, tt.want)
}
})
}
}
func TestRCFilePath(t *testing.T) {
home, _ := os.UserHomeDir()
tests := []struct {
shell string
want string
}{
{"zsh", filepath.Join(home, ".zshrc")},
{"bash", filepath.Join(home, ".bashrc")},
}
for _, tt := range tests {
t.Run(tt.shell, func(t *testing.T) {
got := RCFilePath(tt.shell)
if got != tt.want {
t.Errorf("RCFilePath(%q) = %q, want %q", tt.shell, got, tt.want)
}
})
}
}
func TestAddRemoveFromRCFile(t *testing.T) {
tmpDir := t.TempDir()
rcPath := filepath.Join(tmpDir, ".zshrc")
originalContent := "# existing content\nalias foo=bar\n"
if err := os.WriteFile(rcPath, []byte(originalContent), 0644); err != nil {
t.Fatal(err)
}
if err := addToRCFile(rcPath); err != nil {
t.Fatalf("addToRCFile() error = %v", err)
}
data, err := os.ReadFile(rcPath)
if err != nil {
t.Fatal(err)
}
content := string(data)
if !strings.Contains(content, markerStart) {
t.Error("RC file should contain start marker")
}
if !strings.Contains(content, markerEnd) {
t.Error("RC file should contain end marker")
}
if !strings.Contains(content, "shell-hook.sh") {
t.Error("RC file should source shell-hook.sh")
}
if !strings.Contains(content, "# existing content") {
t.Error("RC file should preserve original content")
}
if err := removeFromRCFile(rcPath); err != nil {
t.Fatalf("removeFromRCFile() error = %v", err)
}
data, err = os.ReadFile(rcPath)
if err != nil {
t.Fatal(err)
}
content = string(data)
if strings.Contains(content, markerStart) {
t.Error("RC file should not contain start marker after removal")
}
if strings.Contains(content, markerEnd) {
t.Error("RC file should not contain end marker after removal")
}
if !strings.Contains(content, "# existing content") {
t.Error("RC file should preserve original content after removal")
}
}
func TestUpdateRCFile(t *testing.T) {
tmpDir := t.TempDir()
rcPath := filepath.Join(tmpDir, ".zshrc")
if err := addToRCFile(rcPath); err != nil {
t.Fatalf("initial addToRCFile() error = %v", err)
}
if err := addToRCFile(rcPath); err != nil {
t.Fatalf("second addToRCFile() error = %v", err)
}
data, _ := os.ReadFile(rcPath)
content := string(data)
startCount := strings.Count(content, markerStart)
if startCount != 1 {
t.Errorf("RC file has %d start markers, want 1", startCount)
}
}