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

View File

@@ -84,14 +84,12 @@ func (v beadsVersion) compare(other beadsVersion) int {
return 0
}
// getBeadsVersion executes `bd --version` and parses the output.
// Returns the version string (e.g., "0.44.0") or error.
func getBeadsVersion() (string, error) {
cmd := exec.Command("bd", "--version")
cmd := exec.Command("bd", "version")
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return "", fmt.Errorf("bd --version failed: %s", string(exitErr.Stderr))
return "", fmt.Errorf("bd version failed: %s", string(exitErr.Stderr))
}
return "", fmt.Errorf("failed to run bd: %w (is beads installed?)", err)
}

View File

@@ -56,9 +56,7 @@ func runCrewAdd(cmd *cobra.Command, args []string) error {
crewGit := git.NewGit(r.Path)
crewMgr := crew.NewManager(r, crewGit)
// Beads for agent bead creation (use mayor/rig where beads.db lives)
// The rig root .beads/ only has config.yaml, no database.
bd := beads.New(filepath.Join(r.Path, "mayor", "rig"))
bd := beads.New(beads.ResolveBeadsDir(r.Path))
// Track results
var created []string

72
internal/cmd/disable.go Normal file
View File

@@ -0,0 +1,72 @@
// ABOUTME: Command to disable Gas Town system-wide.
// ABOUTME: Sets the global state to disabled so tools work vanilla.
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/shell"
"github.com/steveyegge/gastown/internal/state"
"github.com/steveyegge/gastown/internal/style"
)
var disableClean bool
var disableCmd = &cobra.Command{
Use: "disable",
GroupID: GroupConfig,
Short: "Disable Gas Town system-wide",
Long: `Disable Gas Town for all agentic coding tools.
When disabled:
- Shell hooks become no-ops
- Claude Code SessionStart hooks skip 'gt prime'
- Tools work 100% vanilla (no Gas Town behavior)
The workspace (~/gt) is preserved. Use 'gt enable' to re-enable.
Flags:
--clean Also remove shell integration from ~/.zshrc/~/.bashrc
Environment overrides still work:
GASTOWN_ENABLED=1 - Enable for current session only`,
RunE: runDisable,
}
func init() {
disableCmd.Flags().BoolVar(&disableClean, "clean", false,
"Remove shell integration from RC files")
rootCmd.AddCommand(disableCmd)
}
func runDisable(cmd *cobra.Command, args []string) error {
if err := state.Disable(); err != nil {
return fmt.Errorf("disabling Gas Town: %w", err)
}
if disableClean {
if err := removeShellIntegration(); err != nil {
fmt.Printf("%s Could not clean shell integration: %v\n",
style.Warning.Render("!"), err)
} else {
fmt.Println(" Removed shell integration from RC files")
}
}
fmt.Printf("%s Gas Town disabled\n", style.Success.Render("✓"))
fmt.Println()
fmt.Println("All agentic coding tools now work vanilla.")
if !disableClean {
fmt.Printf("Use %s to also remove shell hooks\n",
style.Dim.Render("gt disable --clean"))
}
fmt.Printf("Use %s to re-enable\n", style.Dim.Render("gt enable"))
return nil
}
func removeShellIntegration() error {
return shell.Remove()
}

View File

@@ -108,6 +108,8 @@ func runDoctor(cmd *cobra.Command, args []string) error {
// Register workspace-level checks first (fundamental)
d.RegisterAll(doctor.WorkspaceChecks()...)
d.Register(doctor.NewGlobalStateCheck())
// Register built-in checks
d.Register(doctor.NewTownGitCheck())
d.Register(doctor.NewDaemonCheck())

54
internal/cmd/enable.go Normal file
View File

@@ -0,0 +1,54 @@
// ABOUTME: Command to enable Gas Town system-wide.
// ABOUTME: Sets the global state to enabled for all agentic coding tools.
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/state"
"github.com/steveyegge/gastown/internal/style"
)
var enableCmd = &cobra.Command{
Use: "enable",
GroupID: GroupConfig,
Short: "Enable Gas Town system-wide",
Long: `Enable Gas Town for all agentic coding tools.
When enabled:
- Shell hooks set GT_TOWN_ROOT and GT_RIG environment variables
- Claude Code SessionStart hooks run 'gt prime' for context
- Git repos are auto-registered as rigs (configurable)
Use 'gt disable' to turn off. Use 'gt status --global' to check state.
Environment overrides:
GASTOWN_DISABLED=1 - Disable for current session only
GASTOWN_ENABLED=1 - Enable for current session only`,
RunE: runEnable,
}
func init() {
rootCmd.AddCommand(enableCmd)
}
func runEnable(cmd *cobra.Command, args []string) error {
if err := state.Enable(Version); err != nil {
return fmt.Errorf("enabling Gas Town: %w", err)
}
fmt.Printf("%s Gas Town enabled\n", style.Success.Render("✓"))
fmt.Println()
fmt.Println("Gas Town will now:")
fmt.Println(" • Inject context into Claude Code sessions")
fmt.Println(" • Set GT_TOWN_ROOT and GT_RIG environment variables")
fmt.Println(" • Auto-register git repos as rigs (if configured)")
fmt.Println()
fmt.Printf("Use %s to disable, %s to check status\n",
style.Dim.Render("gt disable"),
style.Dim.Render("gt status --global"))
return nil
}

View File

@@ -16,9 +16,12 @@ import (
"github.com/steveyegge/gastown/internal/deps"
"github.com/steveyegge/gastown/internal/formula"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/shell"
"github.com/steveyegge/gastown/internal/state"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/templates"
"github.com/steveyegge/gastown/internal/workspace"
"github.com/steveyegge/gastown/internal/wrappers"
)
var (
@@ -30,6 +33,8 @@ var (
installGit bool
installGitHub string
installPublic bool
installShell bool
installWrappers bool
)
var installCmd = &cobra.Command{
@@ -55,7 +60,8 @@ Examples:
gt install ~/gt --no-beads # Skip .beads/ initialization
gt install ~/gt --git # Also init git with .gitignore
gt install ~/gt --github=user/repo # Create private GitHub repo (default)
gt install ~/gt --github=user/repo --public # Create public GitHub repo`,
gt install ~/gt --github=user/repo --public # Create public GitHub repo
gt install ~/gt --shell # Install shell integration (sets GT_TOWN_ROOT/GT_RIG)`,
Args: cobra.MaximumNArgs(1),
RunE: runInstall,
}
@@ -69,6 +75,8 @@ func init() {
installCmd.Flags().BoolVar(&installGit, "git", false, "Initialize git with .gitignore")
installCmd.Flags().StringVar(&installGitHub, "github", "", "Create GitHub repo (format: owner/repo, private by default)")
installCmd.Flags().BoolVar(&installPublic, "public", false, "Make GitHub repo public (use with --github)")
installCmd.Flags().BoolVar(&installShell, "shell", false, "Install shell integration (sets GT_TOWN_ROOT/GT_RIG env vars)")
installCmd.Flags().BoolVar(&installWrappers, "wrappers", false, "Install gt-codex/gt-opencode wrapper scripts to ~/bin/")
rootCmd.AddCommand(installCmd)
}
@@ -260,6 +268,29 @@ func runInstall(cmd *cobra.Command, args []string) error {
fmt.Printf(" ✓ Created .claude/commands/ (slash commands for all agents)\n")
}
if installShell {
fmt.Println()
if err := shell.Install(); err != nil {
fmt.Printf(" %s Could not install shell integration: %v\n", style.Dim.Render("⚠"), err)
} else {
fmt.Printf(" ✓ Installed shell integration (%s)\n", shell.RCFilePath(shell.DetectShell()))
}
if err := state.Enable(Version); err != nil {
fmt.Printf(" %s Could not enable Gas Town: %v\n", style.Dim.Render("⚠"), err)
} else {
fmt.Printf(" ✓ Enabled Gas Town globally\n")
}
}
if installWrappers {
fmt.Println()
if err := wrappers.Install(); err != nil {
fmt.Printf(" %s Could not install wrapper scripts: %v\n", style.Dim.Render("⚠"), err)
} else {
fmt.Printf(" ✓ Installed gt-codex and gt-opencode to %s\n", wrappers.BinDir())
}
}
fmt.Printf("\n%s HQ created successfully!\n", style.Bold.Render("✓"))
fmt.Println()
fmt.Println("Next steps:")

View File

@@ -22,6 +22,7 @@ import (
"github.com/steveyegge/gastown/internal/lock"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/state"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/templates"
"github.com/steveyegge/gastown/internal/workspace"
@@ -81,12 +82,15 @@ func init() {
type RoleContext = RoleInfo
func runPrime(cmd *cobra.Command, args []string) error {
if !state.IsEnabled() {
return nil
}
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("getting current directory: %w", err)
}
// Find town root
townRoot, err := workspace.FindFromCwd()
if err != nil {
return fmt.Errorf("finding workspace: %w", err)

147
internal/cmd/rig_detect.go Normal file
View File

@@ -0,0 +1,147 @@
// ABOUTME: Hidden command for shell hook to detect rigs and update cache.
// ABOUTME: Called by shell integration to set GT_TOWN_ROOT and GT_RIG env vars.
package cmd
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/state"
"github.com/steveyegge/gastown/internal/workspace"
)
var rigDetectCache string
var rigDetectCmd = &cobra.Command{
Use: "detect [path]",
Short: "Detect rig from repository path (internal use)",
Hidden: true,
Long: `Detect rig from a repository path and optionally cache the result.
This is an internal command used by shell integration. It checks if the given
path is inside a Gas Town rig and outputs shell variable assignments.
When --cache is specified, the result is written to ~/.cache/gastown/rigs.cache
for fast lookups by the shell hook.
Output format (to stdout):
export GT_TOWN_ROOT=/path/to/town
export GT_RIG=rigname
Or if not in a rig:
unset GT_TOWN_ROOT GT_RIG`,
Args: cobra.MaximumNArgs(1),
RunE: runRigDetect,
}
func init() {
rigCmd.AddCommand(rigDetectCmd)
rigDetectCmd.Flags().StringVar(&rigDetectCache, "cache", "", "Repository path to cache detection result for")
}
func runRigDetect(cmd *cobra.Command, args []string) error {
checkPath := "."
if len(args) > 0 {
checkPath = args[0]
}
absPath, err := filepath.Abs(checkPath)
if err != nil {
return outputNotInRig()
}
townRoot, err := workspace.Find(absPath)
if err != nil || townRoot == "" {
return outputNotInRig()
}
rigName := detectRigFromPath(townRoot, absPath)
if rigName != "" {
fmt.Printf("export GT_TOWN_ROOT=%q\n", townRoot)
fmt.Printf("export GT_RIG=%q\n", rigName)
} else {
fmt.Printf("export GT_TOWN_ROOT=%q\n", townRoot)
fmt.Println("unset GT_RIG")
}
if rigDetectCache != "" {
if err := updateRigCache(rigDetectCache, townRoot, rigName); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not update cache: %v\n", err)
}
}
return nil
}
func detectRigFromPath(townRoot, absPath string) string {
rel, err := filepath.Rel(townRoot, absPath)
if err != nil || strings.HasPrefix(rel, "..") {
return ""
}
parts := strings.Split(rel, string(filepath.Separator))
if len(parts) == 0 || parts[0] == "." {
return ""
}
candidateRig := parts[0]
switch candidateRig {
case "mayor", "deacon", ".beads", ".claude", ".git", "plugins":
return ""
}
rigConfigPath := filepath.Join(townRoot, candidateRig, "config.json")
if _, err := os.Stat(rigConfigPath); err == nil {
return candidateRig
}
return ""
}
func outputNotInRig() error {
fmt.Println("unset GT_TOWN_ROOT GT_RIG")
return nil
}
func updateRigCache(repoRoot, townRoot, rigName string) error {
cacheDir := state.CacheDir()
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return err
}
cachePath := filepath.Join(cacheDir, "rigs.cache")
existing := make(map[string]string)
if data, err := os.ReadFile(cachePath); err == nil {
lines := strings.Split(string(data), "\n")
for _, line := range lines {
if idx := strings.Index(line, ":"); idx > 0 {
existing[line[:idx]] = line[idx+1:]
}
}
}
var value string
if rigName != "" {
value = fmt.Sprintf("export GT_TOWN_ROOT=%q; export GT_RIG=%q", townRoot, rigName)
} else if townRoot != "" {
value = fmt.Sprintf("export GT_TOWN_ROOT=%q; unset GT_RIG", townRoot)
} else {
value = "unset GT_TOWN_ROOT GT_RIG"
}
existing[repoRoot] = value
var lines []string
for k, v := range existing {
lines = append(lines, k+":"+v)
}
return os.WriteFile(cachePath, []byte(strings.Join(lines, "\n")+"\n"), 0644)
}

View File

@@ -0,0 +1,186 @@
// ABOUTME: Quick-add command for adding a repo to Gas Town with minimal friction.
// ABOUTME: Used by shell hook for automatic "add to Gas Town?" prompts.
package cmd
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace"
)
var (
quickAddUser string
quickAddYes bool
quickAddQuiet bool
)
var rigQuickAddCmd = &cobra.Command{
Use: "quick-add [path]",
Short: "Quickly add current repo to Gas Town",
Hidden: true,
Long: `Quickly add a git repository to Gas Town with minimal interaction.
This command is designed for the shell hook's "Add to Gas Town?" prompt.
It infers the rig name from the directory and git URL from the remote.
Examples:
gt rig quick-add # Add current directory
gt rig quick-add ~/Repos/myproject # Add specific path
gt rig quick-add --yes # Non-interactive`,
Args: cobra.MaximumNArgs(1),
RunE: runRigQuickAdd,
}
func init() {
rigCmd.AddCommand(rigQuickAddCmd)
rigQuickAddCmd.Flags().StringVar(&quickAddUser, "user", "", "Crew workspace name (default: $USER)")
rigQuickAddCmd.Flags().BoolVar(&quickAddYes, "yes", false, "Non-interactive, assume yes")
rigQuickAddCmd.Flags().BoolVar(&quickAddQuiet, "quiet", false, "Minimal output")
}
func runRigQuickAdd(cmd *cobra.Command, args []string) error {
targetPath := "."
if len(args) > 0 {
targetPath = args[0]
}
absPath, err := filepath.Abs(targetPath)
if err != nil {
return fmt.Errorf("resolving path: %w", err)
}
if townRoot, err := workspace.Find(absPath); err == nil && townRoot != "" {
return fmt.Errorf("already part of a Gas Town workspace: %s", townRoot)
}
gitRoot, err := findGitRoot(absPath)
if err != nil {
return fmt.Errorf("not a git repository: %w", err)
}
gitURL, err := findGitRemoteURL(gitRoot)
if err != nil {
return fmt.Errorf("no git remote found: %w", err)
}
rigName := sanitizeRigName(filepath.Base(gitRoot))
townRoot, err := findOrCreateTown()
if err != nil {
return fmt.Errorf("finding Gas Town: %w", err)
}
rigPath := filepath.Join(townRoot, rigName)
if _, err := os.Stat(rigPath); err == nil {
return fmt.Errorf("rig %q already exists in %s", rigName, townRoot)
}
originalName := filepath.Base(gitRoot)
if rigName != originalName && !quickAddQuiet {
fmt.Printf("Note: Using %q as rig name (sanitized from %q)\n", rigName, originalName)
}
if !quickAddQuiet {
fmt.Printf("Adding %s to Gas Town...\n", style.Bold.Render(rigName))
fmt.Printf(" Repository: %s\n", gitURL)
fmt.Printf(" Town: %s\n", townRoot)
}
addArgs := []string{"rig", "add", rigName, gitURL}
addCmd := exec.Command("gt", addArgs...)
addCmd.Dir = townRoot
addCmd.Stdout = os.Stdout
addCmd.Stderr = os.Stderr
if err := addCmd.Run(); err != nil {
fmt.Printf("\n%s Failed to add rig. You can try manually:\n", style.Warning.Render("⚠"))
fmt.Printf(" cd %s && gt rig add %s %s\n", townRoot, rigName, gitURL)
return fmt.Errorf("gt rig add failed: %w", err)
}
user := quickAddUser
if user == "" {
user = os.Getenv("USER")
}
if user == "" {
user = "default"
}
if !quickAddQuiet {
fmt.Printf("\nCreating crew workspace for %s...\n", user)
}
crewArgs := []string{"crew", "add", user, "--rig", rigName}
crewCmd := exec.Command("gt", crewArgs...)
crewCmd.Dir = filepath.Join(townRoot, rigName)
crewCmd.Stdout = os.Stdout
crewCmd.Stderr = os.Stderr
if err := crewCmd.Run(); err != nil {
fmt.Printf(" %s Could not create crew workspace: %v\n", style.Dim.Render("⚠"), err)
fmt.Printf(" Run manually: cd %s && gt crew add %s --rig %s\n", filepath.Join(townRoot, rigName), user, rigName)
}
crewPath := filepath.Join(townRoot, rigName, "crew", user)
if !quickAddQuiet {
fmt.Printf("\n%s Added to Gas Town!\n", style.Success.Render("✓"))
fmt.Printf("\nYour workspace: %s\n", style.Bold.Render(crewPath))
}
fmt.Printf("GT_CREW_PATH=%s\n", crewPath)
return nil
}
func findGitRoot(path string) (string, error) {
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
cmd.Dir = path
out, err := cmd.Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
func findGitRemoteURL(gitRoot string) (string, error) {
cmd := exec.Command("git", "remote", "get-url", "origin")
cmd.Dir = gitRoot
out, err := cmd.Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
func sanitizeRigName(name string) string {
name = strings.ReplaceAll(name, "-", "_")
name = strings.ReplaceAll(name, ".", "_")
name = strings.ReplaceAll(name, " ", "_")
return name
}
func findOrCreateTown() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
candidates := []string{
filepath.Join(home, "gt"),
filepath.Join(home, "gastown"),
}
for _, path := range candidates {
mayorDir := filepath.Join(path, "mayor")
if _, err := os.Stat(mayorDir); err == nil {
return path, nil
}
}
return "", fmt.Errorf("no Gas Town found - run 'gt install ~/gt' first")
}

99
internal/cmd/shell.go Normal file
View File

@@ -0,0 +1,99 @@
// ABOUTME: Shell integration management commands.
// ABOUTME: Install/remove shell hooks without full HQ setup.
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/shell"
"github.com/steveyegge/gastown/internal/state"
"github.com/steveyegge/gastown/internal/style"
)
var shellCmd = &cobra.Command{
Use: "shell",
GroupID: GroupConfig,
Short: "Manage shell integration",
RunE: requireSubcommand,
}
var shellInstallCmd = &cobra.Command{
Use: "install",
Short: "Install or update shell integration",
Long: `Install or update the Gas Town shell integration.
This adds a hook to your shell RC file that:
- Sets GT_TOWN_ROOT and GT_RIG when you cd into a Gas Town rig
- Offers to add new git repos to Gas Town on first visit
Run this after upgrading gt to get the latest shell hook features.`,
RunE: runShellInstall,
}
var shellRemoveCmd = &cobra.Command{
Use: "remove",
Short: "Remove shell integration",
RunE: runShellRemove,
}
var shellStatusCmd = &cobra.Command{
Use: "status",
Short: "Show shell integration status",
RunE: runShellStatus,
}
func init() {
shellCmd.AddCommand(shellInstallCmd)
shellCmd.AddCommand(shellRemoveCmd)
shellCmd.AddCommand(shellStatusCmd)
rootCmd.AddCommand(shellCmd)
}
func runShellInstall(cmd *cobra.Command, args []string) error {
if err := shell.Install(); err != nil {
return err
}
if err := state.Enable(Version); err != nil {
fmt.Printf("%s Could not enable Gas Town: %v\n", style.Dim.Render("⚠"), err)
}
fmt.Printf("%s Shell integration installed (%s)\n", style.Success.Render("✓"), shell.RCFilePath(shell.DetectShell()))
fmt.Println()
fmt.Println("Run 'source ~/.zshrc' or open a new terminal to activate.")
return nil
}
func runShellRemove(cmd *cobra.Command, args []string) error {
if err := shell.Remove(); err != nil {
return err
}
fmt.Printf("%s Shell integration removed\n", style.Success.Render("✓"))
return nil
}
func runShellStatus(cmd *cobra.Command, args []string) error {
s, err := state.Load()
if err != nil {
fmt.Println("Gas Town: not configured")
fmt.Println("Shell integration: not installed")
return nil
}
if s.Enabled {
fmt.Println("Gas Town: enabled")
} else {
fmt.Println("Gas Town: disabled")
}
if s.ShellIntegration != "" {
fmt.Printf("Shell integration: %s (%s)\n", s.ShellIntegration, shell.RCFilePath(s.ShellIntegration))
} else {
fmt.Println("Shell integration: not installed")
}
return nil
}

171
internal/cmd/uninstall.go Normal file
View File

@@ -0,0 +1,171 @@
// ABOUTME: Command to completely uninstall Gas Town from the system.
// ABOUTME: Removes shell integration, wrappers, state, and optionally workspace.
package cmd
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/shell"
"github.com/steveyegge/gastown/internal/state"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/wrappers"
)
var (
uninstallWorkspace bool
uninstallForce bool
)
var uninstallCmd = &cobra.Command{
Use: "uninstall",
GroupID: GroupConfig,
Short: "Remove Gas Town from the system",
Long: `Completely remove Gas Town from the system.
By default, removes:
- Shell integration (~/.zshrc or ~/.bashrc)
- Wrapper scripts (~/bin/gt-codex, ~/bin/gt-opencode)
- State directory (~/.local/state/gastown/)
- Config directory (~/.config/gastown/)
- Cache directory (~/.cache/gastown/)
The workspace (e.g., ~/gt) is NOT removed unless --workspace is specified.
Use --force to skip confirmation prompts.
Examples:
gt uninstall # Remove Gas Town, keep workspace
gt uninstall --workspace # Also remove workspace directory
gt uninstall --force # Skip confirmation`,
RunE: runUninstall,
}
func init() {
uninstallCmd.Flags().BoolVar(&uninstallWorkspace, "workspace", false,
"Also remove the workspace directory (DESTRUCTIVE)")
uninstallCmd.Flags().BoolVarP(&uninstallForce, "force", "f", false,
"Skip confirmation prompts")
rootCmd.AddCommand(uninstallCmd)
}
func runUninstall(cmd *cobra.Command, args []string) error {
if !uninstallForce {
fmt.Println("This will remove Gas Town from your system.")
fmt.Println()
fmt.Println("The following will be removed:")
fmt.Printf(" • Shell integration (%s)\n", shell.RCFilePath(shell.DetectShell()))
fmt.Printf(" • Wrapper scripts (%s)\n", wrappers.BinDir())
fmt.Printf(" • State directory (%s)\n", state.StateDir())
fmt.Printf(" • Config directory (%s)\n", state.ConfigDir())
fmt.Printf(" • Cache directory (%s)\n", state.CacheDir())
if uninstallWorkspace {
fmt.Println()
fmt.Printf(" %s WORKSPACE WILL BE DELETED\n", style.Warning.Render("⚠"))
fmt.Println(" This cannot be undone!")
}
fmt.Println()
fmt.Print("Continue? [y/N] ")
reader := bufio.NewReader(os.Stdin)
response, _ := reader.ReadString('\n')
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
fmt.Println("Aborted.")
return nil
}
}
var errors []string
fmt.Println()
fmt.Println("Removing Gas Town...")
if err := shell.Remove(); err != nil {
errors = append(errors, fmt.Sprintf("shell integration: %v", err))
} else {
fmt.Printf(" %s Removed shell integration\n", style.Success.Render("✓"))
}
if err := wrappers.Remove(); err != nil {
errors = append(errors, fmt.Sprintf("wrapper scripts: %v", err))
} else {
fmt.Printf(" %s Removed wrapper scripts\n", style.Success.Render("✓"))
}
if err := os.RemoveAll(state.StateDir()); err != nil && !os.IsNotExist(err) {
errors = append(errors, fmt.Sprintf("state directory: %v", err))
} else {
fmt.Printf(" %s Removed state directory\n", style.Success.Render("✓"))
}
if err := os.RemoveAll(state.ConfigDir()); err != nil && !os.IsNotExist(err) {
errors = append(errors, fmt.Sprintf("config directory: %v", err))
} else {
fmt.Printf(" %s Removed config directory\n", style.Success.Render("✓"))
}
if err := os.RemoveAll(state.CacheDir()); err != nil && !os.IsNotExist(err) {
errors = append(errors, fmt.Sprintf("cache directory: %v", err))
} else {
fmt.Printf(" %s Removed cache directory\n", style.Success.Render("✓"))
}
if uninstallWorkspace {
workspaceDir := findWorkspaceForUninstall()
if workspaceDir != "" {
if err := os.RemoveAll(workspaceDir); err != nil {
errors = append(errors, fmt.Sprintf("workspace: %v", err))
} else {
fmt.Printf(" %s Removed workspace: %s\n", style.Success.Render("✓"), workspaceDir)
}
}
}
if len(errors) > 0 {
fmt.Println()
fmt.Printf("%s Some components could not be removed:\n", style.Warning.Render("⚠"))
for _, e := range errors {
fmt.Printf(" • %s\n", e)
}
return fmt.Errorf("uninstall incomplete")
}
fmt.Println()
fmt.Printf("%s Gas Town has been uninstalled\n", style.Success.Render("✓"))
fmt.Println()
fmt.Println("To reinstall, run:")
fmt.Printf(" %s\n", style.Dim.Render("go install github.com/steveyegge/gastown/cmd/gt@latest"))
fmt.Printf(" %s\n", style.Dim.Render("gt install ~/gt --shell"))
return nil
}
func findWorkspaceForUninstall() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
candidates := []string{
filepath.Join(home, "gt"),
filepath.Join(home, "gastown"),
}
for _, path := range candidates {
mayorDir := filepath.Join(path, "mayor")
if _, err := os.Stat(mayorDir); err == nil {
return path
}
}
return ""
}