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 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) { func getBeadsVersion() (string, error) {
cmd := exec.Command("bd", "--version") cmd := exec.Command("bd", "version")
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok { 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) 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) crewGit := git.NewGit(r.Path)
crewMgr := crew.NewManager(r, crewGit) crewMgr := crew.NewManager(r, crewGit)
// Beads for agent bead creation (use mayor/rig where beads.db lives) bd := beads.New(beads.ResolveBeadsDir(r.Path))
// The rig root .beads/ only has config.yaml, no database.
bd := beads.New(filepath.Join(r.Path, "mayor", "rig"))
// Track results // Track results
var created []string 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) // Register workspace-level checks first (fundamental)
d.RegisterAll(doctor.WorkspaceChecks()...) d.RegisterAll(doctor.WorkspaceChecks()...)
d.Register(doctor.NewGlobalStateCheck())
// Register built-in checks // Register built-in checks
d.Register(doctor.NewTownGitCheck()) d.Register(doctor.NewTownGitCheck())
d.Register(doctor.NewDaemonCheck()) 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/deps"
"github.com/steveyegge/gastown/internal/formula" "github.com/steveyegge/gastown/internal/formula"
"github.com/steveyegge/gastown/internal/session" "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/style"
"github.com/steveyegge/gastown/internal/templates" "github.com/steveyegge/gastown/internal/templates"
"github.com/steveyegge/gastown/internal/workspace" "github.com/steveyegge/gastown/internal/workspace"
"github.com/steveyegge/gastown/internal/wrappers"
) )
var ( var (
@@ -30,6 +33,8 @@ var (
installGit bool installGit bool
installGitHub string installGitHub string
installPublic bool installPublic bool
installShell bool
installWrappers bool
) )
var installCmd = &cobra.Command{ var installCmd = &cobra.Command{
@@ -55,7 +60,8 @@ Examples:
gt install ~/gt --no-beads # Skip .beads/ initialization gt install ~/gt --no-beads # Skip .beads/ initialization
gt install ~/gt --git # Also init git with .gitignore 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 # 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), Args: cobra.MaximumNArgs(1),
RunE: runInstall, RunE: runInstall,
} }
@@ -69,6 +75,8 @@ func init() {
installCmd.Flags().BoolVar(&installGit, "git", false, "Initialize git with .gitignore") 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().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(&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) 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") 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.Printf("\n%s HQ created successfully!\n", style.Bold.Render("✓"))
fmt.Println() fmt.Println()
fmt.Println("Next steps:") fmt.Println("Next steps:")

View File

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

View File

@@ -225,8 +225,9 @@ type RuntimeConfig struct {
Command string `json:"command,omitempty"` Command string `json:"command,omitempty"`
// Args are additional command-line arguments. // Args are additional command-line arguments.
// Default: ["--dangerously-skip-permissions"] // Default: ["--dangerously-skip-permissions"] for built-in agents.
Args []string `json:"args,omitempty"` // Empty array [] means no args (not "use defaults").
Args []string `json:"args"`
// InitialPrompt is an optional first message to send after startup. // InitialPrompt is an optional first message to send after startup.
// For claude, this is passed as the prompt argument. // For claude, this is passed as the prompt argument.

View File

@@ -0,0 +1,118 @@
// ABOUTME: Doctor check for Gas Town global state configuration.
// ABOUTME: Validates that state directories and shell integration are properly configured.
package doctor
import (
"os"
"path/filepath"
"github.com/steveyegge/gastown/internal/shell"
"github.com/steveyegge/gastown/internal/state"
)
type GlobalStateCheck struct {
BaseCheck
}
func NewGlobalStateCheck() *GlobalStateCheck {
return &GlobalStateCheck{
BaseCheck: BaseCheck{
CheckName: "global-state",
CheckDescription: "Validates Gas Town global state and shell integration",
},
}
}
func (c *GlobalStateCheck) Run(ctx *CheckContext) *CheckResult {
result := &CheckResult{
Name: c.Name(),
Status: StatusOK,
}
var details []string
var warnings []string
var errors []string
s, err := state.Load()
if err != nil {
if os.IsNotExist(err) {
result.Message = "Global state not initialized"
result.FixHint = "Run: gt enable"
result.Status = StatusWarning
return result
}
result.Message = "Cannot read global state"
result.Details = []string{err.Error()}
result.Status = StatusError
return result
}
if s.Enabled {
details = append(details, "Gas Town: enabled")
} else {
details = append(details, "Gas Town: disabled")
warnings = append(warnings, "Gas Town is disabled globally")
}
if s.Version != "" {
details = append(details, "Version: "+s.Version)
}
if s.MachineID != "" {
details = append(details, "Machine ID: "+s.MachineID)
}
rcPath := shell.RCFilePath(shell.DetectShell())
if hasShellIntegration(rcPath) {
details = append(details, "Shell integration: installed ("+rcPath+")")
} else {
warnings = append(warnings, "Shell integration not installed")
}
hookPath := filepath.Join(state.ConfigDir(), "shell-hook.sh")
if _, err := os.Stat(hookPath); err == nil {
details = append(details, "Hook script: present")
} else {
if hasShellIntegration(rcPath) {
errors = append(errors, "Hook script missing but shell integration installed")
}
}
result.Details = details
if len(errors) > 0 {
result.Status = StatusError
result.Message = errors[0]
result.FixHint = "Run: gt install --shell"
} else if len(warnings) > 0 {
result.Status = StatusWarning
result.Message = warnings[0]
if !s.Enabled {
result.FixHint = "Run: gt enable"
} else {
result.FixHint = "Run: gt install --shell"
}
} else {
result.Message = "Global state healthy"
}
return result
}
func hasShellIntegration(rcPath string) bool {
data, err := os.ReadFile(rcPath)
if err != nil {
return false
}
return len(data) > 0 && contains(string(data), "Gas Town Integration")
}
func contains(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

View File

@@ -614,6 +614,11 @@ func (m *Manager) initBeads(rigPath, prefix string) error {
fmt.Printf(" ⚠ Could not add route to town beads: %v\n", err) fmt.Printf(" ⚠ Could not add route to town beads: %v\n", err)
} }
typesCmd := exec.Command("bd", "config", "set", "types.custom", "agent,role,rig,convoy,slot")
typesCmd.Dir = rigPath
typesCmd.Env = filteredEnv
_, _ = typesCmd.CombinedOutput()
return nil return nil
} }

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
`

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)
}
}

188
internal/state/state.go Normal file
View File

@@ -0,0 +1,188 @@
// ABOUTME: Global state management for Gas Town enable/disable toggle.
// ABOUTME: Uses XDG-compliant paths for per-machine state storage.
package state
import (
"encoding/json"
"os"
"path/filepath"
"time"
"github.com/google/uuid"
)
// State represents the global Gas Town state.
type State struct {
Enabled bool `json:"enabled"`
Version string `json:"version"`
MachineID string `json:"machine_id"`
InstalledAt time.Time `json:"installed_at"`
UpdatedAt time.Time `json:"updated_at"`
ShellIntegration string `json:"shell_integration,omitempty"`
LastDoctorRun time.Time `json:"last_doctor_run,omitempty"`
}
// StateDir returns the XDG-compliant state directory.
// Uses ~/.local/state/gastown/ (per XDG Base Directory Specification).
func StateDir() string {
// Check XDG_STATE_HOME first
if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" {
return filepath.Join(xdg, "gastown")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".local", "state", "gastown")
}
// ConfigDir returns the XDG-compliant config directory.
// Uses ~/.config/gastown/
func ConfigDir() string {
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, "gastown")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "gastown")
}
// CacheDir returns the XDG-compliant cache directory.
// Uses ~/.cache/gastown/
func CacheDir() string {
if xdg := os.Getenv("XDG_CACHE_HOME"); xdg != "" {
return filepath.Join(xdg, "gastown")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".cache", "gastown")
}
// StatePath returns the path to state.json.
func StatePath() string {
return filepath.Join(StateDir(), "state.json")
}
// IsEnabled checks if Gas Town is globally enabled.
// Priority: env override > state file > default (false)
func IsEnabled() bool {
// Environment overrides take priority
if os.Getenv("GASTOWN_DISABLED") == "1" {
return false
}
if os.Getenv("GASTOWN_ENABLED") == "1" {
return true
}
// Check state file
state, err := Load()
if err != nil {
return false // Default to disabled if state unreadable
}
return state.Enabled
}
// Load reads the state from disk.
func Load() (*State, error) {
data, err := os.ReadFile(StatePath())
if os.IsNotExist(err) {
return nil, err
}
if err != nil {
return nil, err
}
var state State
if err := json.Unmarshal(data, &state); err != nil {
return nil, err
}
return &state, nil
}
// Save writes the state to disk atomically.
func Save(s *State) error {
dir := StateDir()
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
s.UpdatedAt = time.Now()
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
// Atomic write via temp file
tmp := StatePath() + ".tmp"
if err := os.WriteFile(tmp, data, 0600); err != nil {
return err
}
return os.Rename(tmp, StatePath())
}
// Enable enables Gas Town globally.
func Enable(version string) error {
s, err := Load()
if err != nil {
// Create new state
s = &State{
InstalledAt: time.Now(),
MachineID: generateMachineID(),
}
}
s.Enabled = true
s.Version = version
return Save(s)
}
// Disable disables Gas Town globally.
func Disable() error {
s, err := Load()
if err != nil {
// Nothing to disable, create disabled state
s = &State{
InstalledAt: time.Now(),
MachineID: generateMachineID(),
Enabled: false,
}
return Save(s)
}
s.Enabled = false
return Save(s)
}
// generateMachineID creates a unique machine identifier.
func generateMachineID() string {
return uuid.New().String()[:8]
}
// GetMachineID returns the machine ID, creating one if needed.
func GetMachineID() string {
s, err := Load()
if err != nil || s.MachineID == "" {
return generateMachineID()
}
return s.MachineID
}
// SetShellIntegration records which shell integration is installed.
func SetShellIntegration(shell string) error {
s, err := Load()
if err != nil {
s = &State{
InstalledAt: time.Now(),
MachineID: generateMachineID(),
}
}
s.ShellIntegration = shell
return Save(s)
}
// RecordDoctorRun records when doctor was last run.
func RecordDoctorRun() error {
s, err := Load()
if err != nil {
return err
}
s.LastDoctorRun = time.Now()
return Save(s)
}

View File

@@ -0,0 +1,131 @@
// ABOUTME: Tests for global state management.
// ABOUTME: Verifies enable/disable toggle and XDG path resolution.
package state
import (
"os"
"path/filepath"
"testing"
)
func TestStateDir(t *testing.T) {
home, _ := os.UserHomeDir()
expected := filepath.Join(home, ".local", "state", "gastown")
os.Unsetenv("XDG_STATE_HOME")
if got := StateDir(); got != expected {
t.Errorf("StateDir() = %q, want %q", got, expected)
}
os.Setenv("XDG_STATE_HOME", "/custom/state")
defer os.Unsetenv("XDG_STATE_HOME")
if got := StateDir(); got != "/custom/state/gastown" {
t.Errorf("StateDir() with XDG = %q, want /custom/state/gastown", got)
}
}
func TestConfigDir(t *testing.T) {
home, _ := os.UserHomeDir()
expected := filepath.Join(home, ".config", "gastown")
os.Unsetenv("XDG_CONFIG_HOME")
if got := ConfigDir(); got != expected {
t.Errorf("ConfigDir() = %q, want %q", got, expected)
}
os.Setenv("XDG_CONFIG_HOME", "/custom/config")
defer os.Unsetenv("XDG_CONFIG_HOME")
if got := ConfigDir(); got != "/custom/config/gastown" {
t.Errorf("ConfigDir() with XDG = %q, want /custom/config/gastown", got)
}
}
func TestCacheDir(t *testing.T) {
home, _ := os.UserHomeDir()
expected := filepath.Join(home, ".cache", "gastown")
os.Unsetenv("XDG_CACHE_HOME")
if got := CacheDir(); got != expected {
t.Errorf("CacheDir() = %q, want %q", got, expected)
}
os.Setenv("XDG_CACHE_HOME", "/custom/cache")
defer os.Unsetenv("XDG_CACHE_HOME")
if got := CacheDir(); got != "/custom/cache/gastown" {
t.Errorf("CacheDir() with XDG = %q, want /custom/cache/gastown", got)
}
}
func TestIsEnabled_EnvOverride(t *testing.T) {
os.Setenv("GASTOWN_DISABLED", "1")
defer os.Unsetenv("GASTOWN_DISABLED")
if IsEnabled() {
t.Error("IsEnabled() should return false when GASTOWN_DISABLED=1")
}
os.Unsetenv("GASTOWN_DISABLED")
os.Setenv("GASTOWN_ENABLED", "1")
defer os.Unsetenv("GASTOWN_ENABLED")
if !IsEnabled() {
t.Error("IsEnabled() should return true when GASTOWN_ENABLED=1")
}
}
func TestIsEnabled_DisabledOverridesEnabled(t *testing.T) {
os.Setenv("GASTOWN_DISABLED", "1")
os.Setenv("GASTOWN_ENABLED", "1")
defer os.Unsetenv("GASTOWN_DISABLED")
defer os.Unsetenv("GASTOWN_ENABLED")
if IsEnabled() {
t.Error("GASTOWN_DISABLED should take precedence over GASTOWN_ENABLED")
}
}
func TestEnableDisable(t *testing.T) {
tmpDir := t.TempDir()
os.Setenv("XDG_STATE_HOME", tmpDir)
defer os.Unsetenv("XDG_STATE_HOME")
os.Unsetenv("GASTOWN_DISABLED")
os.Unsetenv("GASTOWN_ENABLED")
if err := Enable("1.0.0"); err != nil {
t.Fatalf("Enable() failed: %v", err)
}
if !IsEnabled() {
t.Error("IsEnabled() should return true after Enable()")
}
s, err := Load()
if err != nil {
t.Fatalf("Load() failed: %v", err)
}
if s.Version != "1.0.0" {
t.Errorf("State.Version = %q, want %q", s.Version, "1.0.0")
}
if s.MachineID == "" {
t.Error("State.MachineID should not be empty")
}
if err := Disable(); err != nil {
t.Fatalf("Disable() failed: %v", err)
}
if IsEnabled() {
t.Error("IsEnabled() should return false after Disable()")
}
}
func TestGenerateMachineID(t *testing.T) {
id1 := generateMachineID()
id2 := generateMachineID()
if len(id1) != 8 {
t.Errorf("generateMachineID() length = %d, want 8", len(id1))
}
if id1 == id2 {
t.Error("generateMachineID() should generate unique IDs")
}
}

View File

@@ -0,0 +1,18 @@
#!/bin/bash
# ABOUTME: Wrapper script that runs gt prime before launching codex.
# ABOUTME: Ensures Gas Town context is available in Codex sessions.
set -e
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
}
if gastown_enabled && command -v gt &>/dev/null; then
gt prime 2>/dev/null || true
fi
exec codex "$@"

View File

@@ -0,0 +1,18 @@
#!/bin/bash
# ABOUTME: Wrapper script that runs gt prime before launching opencode.
# ABOUTME: Ensures Gas Town context is available in OpenCode sessions.
set -e
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
}
if gastown_enabled && command -v gt &>/dev/null; then
gt prime 2>/dev/null || true
fi
exec opencode "$@"

View File

@@ -0,0 +1,68 @@
// ABOUTME: Manages wrapper scripts for non-Claude agentic coding tools.
// ABOUTME: Provides gt-codex and gt-opencode wrappers that run gt prime first.
package wrappers
import (
"embed"
"fmt"
"os"
"path/filepath"
)
//go:embed scripts/*
var scriptsFS embed.FS
func Install() error {
binDir, err := binPath()
if err != nil {
return fmt.Errorf("determining bin directory: %w", err)
}
if err := os.MkdirAll(binDir, 0755); err != nil {
return fmt.Errorf("creating bin directory: %w", err)
}
wrappers := []string{"gt-codex", "gt-opencode"}
for _, name := range wrappers {
content, err := scriptsFS.ReadFile("scripts/" + name)
if err != nil {
return fmt.Errorf("reading embedded %s: %w", name, err)
}
destPath := filepath.Join(binDir, name)
if err := os.WriteFile(destPath, content, 0755); err != nil {
return fmt.Errorf("writing %s: %w", name, err)
}
}
return nil
}
func Remove() error {
binDir, err := binPath()
if err != nil {
return err
}
wrappers := []string{"gt-codex", "gt-opencode"}
for _, name := range wrappers {
destPath := filepath.Join(binDir, name)
os.Remove(destPath)
}
return nil
}
func binPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, "bin"), nil
}
func BinDir() string {
p, _ := binPath()
return p
}