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:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
72
internal/cmd/disable.go
Normal 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()
|
||||||
|
}
|
||||||
@@ -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
54
internal/cmd/enable.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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:")
|
||||||
|
|||||||
@@ -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
147
internal/cmd/rig_detect.go
Normal 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)
|
||||||
|
}
|
||||||
186
internal/cmd/rig_quick_add.go
Normal file
186
internal/cmd/rig_quick_add.go
Normal 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
99
internal/cmd/shell.go
Normal 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
171
internal/cmd/uninstall.go
Normal 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 ""
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
118
internal/doctor/global_state_check.go
Normal file
118
internal/doctor/global_state_check.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
299
internal/shell/integration.go
Normal file
299
internal/shell/integration.go
Normal 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
internal/shell/integration_test.go
Normal file
132
internal/shell/integration_test.go
Normal 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
188
internal/state/state.go
Normal 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)
|
||||||
|
}
|
||||||
131
internal/state/state_test.go
Normal file
131
internal/state/state_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
18
internal/wrappers/scripts/gt-codex
Executable file
18
internal/wrappers/scripts/gt-codex
Executable 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 "$@"
|
||||||
18
internal/wrappers/scripts/gt-opencode
Executable file
18
internal/wrappers/scripts/gt-opencode
Executable 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 "$@"
|
||||||
68
internal/wrappers/wrappers.go
Normal file
68
internal/wrappers/wrappers.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user