diff --git a/internal/cmd/dog.go b/internal/cmd/dog.go new file mode 100644 index 00000000..fda3815e --- /dev/null +++ b/internal/cmd/dog.go @@ -0,0 +1,558 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/config" + "github.com/steveyegge/gastown/internal/dog" + "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/tmux" + "github.com/steveyegge/gastown/internal/workspace" +) + +// Dog command flags +var ( + dogListJSON bool + dogStatusJSON bool + dogForce bool + dogRemoveAll bool + dogCallAll bool +) + +var dogCmd = &cobra.Command{ + Use: "dog", + Aliases: []string{"dogs"}, + GroupID: GroupAgents, + Short: "Manage dogs (Deacon's helper workers)", + Long: `Manage dogs in the kennel. + +Dogs are reusable helper workers managed by the Deacon for infrastructure +and cleanup tasks. Unlike polecats (single-rig, ephemeral), dogs handle +cross-rig infrastructure work with worktrees into each rig. + +The kennel is located at ~/gt/deacon/dogs/.`, +} + +var dogAddCmd = &cobra.Command{ + Use: "add ", + Short: "Create a new dog in the kennel", + Long: `Create a new dog in the kennel with multi-rig worktrees. + +Each dog gets a worktree per configured rig (e.g., gastown, beads). +The dog starts in idle state, ready to receive work from the Deacon. + +Example: + gt dog add alpha + gt dog add bravo`, + Args: cobra.ExactArgs(1), + RunE: runDogAdd, +} + +var dogRemoveCmd = &cobra.Command{ + Use: "remove ... | --all", + Short: "Remove dogs from the kennel", + Long: `Remove one or more dogs from the kennel. + +Removes all worktrees and the dog directory. +Use --force to remove even if dog is in working state. + +Examples: + gt dog remove alpha + gt dog remove alpha bravo + gt dog remove --all + gt dog remove alpha --force`, + Args: func(cmd *cobra.Command, args []string) error { + if dogRemoveAll { + return nil + } + if len(args) < 1 { + return fmt.Errorf("requires at least 1 dog name (or use --all)") + } + return nil + }, + RunE: runDogRemove, +} + +var dogListCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List all dogs in the kennel", + Long: `List all dogs in the kennel with their status. + +Shows each dog's state (idle/working), current work assignment, +and last active timestamp. + +Examples: + gt dog list + gt dog list --json`, + RunE: runDogList, +} + +var dogCallCmd = &cobra.Command{ + Use: "call [name]", + Short: "Wake idle dog(s) for work", + Long: `Wake an idle dog to prepare for work. + +With a name, wakes the specific dog. +With --all, wakes all idle dogs. +Without arguments, wakes one idle dog (if available). + +This updates the dog's last-active timestamp and can trigger +session creation for the dog's worktrees. + +Examples: + gt dog call alpha + gt dog call --all + gt dog call`, + RunE: runDogCall, +} + +var dogStatusCmd = &cobra.Command{ + Use: "status [name]", + Short: "Show detailed dog status", + Long: `Show detailed status for a specific dog or summary for all dogs. + +With a name, shows detailed info including: + - State (idle/working) + - Current work assignment + - Worktree paths per rig + - Last active timestamp + +Without a name, shows pack summary: + - Total dogs + - Idle/working counts + - Pack health + +Examples: + gt dog status alpha + gt dog status + gt dog status --json`, + RunE: runDogStatus, +} + +func init() { + // List flags + dogListCmd.Flags().BoolVar(&dogListJSON, "json", false, "Output as JSON") + + // Remove flags + dogRemoveCmd.Flags().BoolVarP(&dogForce, "force", "f", false, "Force removal even if working") + dogRemoveCmd.Flags().BoolVar(&dogRemoveAll, "all", false, "Remove all dogs") + + // Call flags + dogCallCmd.Flags().BoolVar(&dogCallAll, "all", false, "Wake all idle dogs") + + // Status flags + dogStatusCmd.Flags().BoolVar(&dogStatusJSON, "json", false, "Output as JSON") + + // Add subcommands + dogCmd.AddCommand(dogAddCmd) + dogCmd.AddCommand(dogRemoveCmd) + dogCmd.AddCommand(dogListCmd) + dogCmd.AddCommand(dogCallCmd) + dogCmd.AddCommand(dogStatusCmd) + + rootCmd.AddCommand(dogCmd) +} + +// getDogManager creates a dog.Manager with the current town root. +func getDogManager() (*dog.Manager, error) { + townRoot, err := workspace.FindFromCwd() + if err != nil { + return nil, fmt.Errorf("finding town root: %w", err) + } + + rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json") + rigsConfig, err := config.LoadRigsConfig(rigsConfigPath) + if err != nil { + return nil, fmt.Errorf("loading rigs config: %w", err) + } + + return dog.NewManager(townRoot, rigsConfig), nil +} + +func runDogAdd(cmd *cobra.Command, args []string) error { + name := args[0] + + // Validate name + if strings.ContainsAny(name, "/\\. ") { + return fmt.Errorf("dog name cannot contain /, \\, ., or spaces") + } + + mgr, err := getDogManager() + if err != nil { + return err + } + + d, err := mgr.Add(name) + if err != nil { + return fmt.Errorf("adding dog %s: %w", name, err) + } + + fmt.Printf("✓ Created dog %s in kennel\n", style.Bold.Render(name)) + fmt.Printf(" Path: %s\n", d.Path) + fmt.Printf(" Worktrees:\n") + for rigName, path := range d.Worktrees { + fmt.Printf(" %s: %s\n", rigName, path) + } + + return nil +} + +func runDogRemove(cmd *cobra.Command, args []string) error { + mgr, err := getDogManager() + if err != nil { + return err + } + + var names []string + if dogRemoveAll { + dogs, err := mgr.List() + if err != nil { + return fmt.Errorf("listing dogs: %w", err) + } + for _, d := range dogs { + names = append(names, d.Name) + } + if len(names) == 0 { + fmt.Println("No dogs in kennel") + return nil + } + } else { + names = args + } + + for _, name := range names { + d, err := mgr.Get(name) + if err != nil { + fmt.Printf("Warning: dog %s not found, skipping\n", name) + continue + } + + // Check if working + if d.State == dog.StateWorking && !dogForce { + return fmt.Errorf("dog %s is working (use --force to remove anyway)", name) + } + + if err := mgr.Remove(name); err != nil { + return fmt.Errorf("removing dog %s: %w", name, err) + } + + fmt.Printf("✓ Removed dog %s\n", name) + } + + return nil +} + +func runDogList(cmd *cobra.Command, args []string) error { + mgr, err := getDogManager() + if err != nil { + return err + } + + dogs, err := mgr.List() + if err != nil { + return fmt.Errorf("listing dogs: %w", err) + } + + if len(dogs) == 0 { + if dogListJSON { + fmt.Println("[]") + } else { + fmt.Println("No dogs in kennel") + } + return nil + } + + if dogListJSON { + type DogListItem struct { + Name string `json:"name"` + State dog.State `json:"state"` + Work string `json:"work,omitempty"` + LastActive time.Time `json:"last_active"` + Worktrees map[string]string `json:"worktrees,omitempty"` + } + + var items []DogListItem + for _, d := range dogs { + items = append(items, DogListItem{ + Name: d.Name, + State: d.State, + Work: d.Work, + LastActive: d.LastActive, + Worktrees: d.Worktrees, + }) + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(items) + } + + // Pretty print + fmt.Println(style.Bold.Render("The Pack")) + fmt.Println() + + idleCount := 0 + workingCount := 0 + + for _, d := range dogs { + stateIcon := "○" + stateStyle := style.Dim + if d.State == dog.StateWorking { + stateIcon = "●" + stateStyle = style.Bold + workingCount++ + } else { + idleCount++ + } + + line := fmt.Sprintf(" %s %s", stateIcon, stateStyle.Render(d.Name)) + if d.Work != "" { + line += fmt.Sprintf(" → %s", style.Dim.Render(d.Work)) + } + fmt.Println(line) + } + + fmt.Println() + fmt.Printf(" %d idle, %d working\n", idleCount, workingCount) + + return nil +} + +func runDogCall(cmd *cobra.Command, args []string) error { + mgr, err := getDogManager() + if err != nil { + return err + } + + if dogCallAll { + // Wake all idle dogs + dogs, err := mgr.List() + if err != nil { + return fmt.Errorf("listing dogs: %w", err) + } + + woken := 0 + for _, d := range dogs { + if d.State == dog.StateIdle { + if err := mgr.SetState(d.Name, dog.StateIdle); err != nil { + fmt.Printf("Warning: failed to wake %s: %v\n", d.Name, err) + continue + } + woken++ + fmt.Printf("✓ Called %s\n", d.Name) + } + } + + if woken == 0 { + fmt.Println("No idle dogs to call") + } else { + fmt.Printf("\n%d dog(s) ready\n", woken) + } + return nil + } + + if len(args) > 0 { + // Wake specific dog + name := args[0] + d, err := mgr.Get(name) + if err != nil { + return fmt.Errorf("getting dog %s: %w", name, err) + } + + if d.State == dog.StateWorking { + fmt.Printf("Dog %s is already working\n", name) + return nil + } + + if err := mgr.SetState(name, dog.StateIdle); err != nil { + return fmt.Errorf("waking dog %s: %w", name, err) + } + + fmt.Printf("✓ Called %s - ready for work\n", name) + return nil + } + + // Wake one idle dog + d, err := mgr.GetIdleDog() + if err != nil { + return fmt.Errorf("getting idle dog: %w", err) + } + + if d == nil { + fmt.Println("No idle dogs available") + return nil + } + + if err := mgr.SetState(d.Name, dog.StateIdle); err != nil { + return fmt.Errorf("waking dog %s: %w", d.Name, err) + } + + fmt.Printf("✓ Called %s - ready for work\n", d.Name) + return nil +} + +func runDogStatus(cmd *cobra.Command, args []string) error { + mgr, err := getDogManager() + if err != nil { + return err + } + + if len(args) > 0 { + // Show specific dog status + name := args[0] + return showDogStatus(mgr, name) + } + + // Show pack summary + return showPackStatus(mgr) +} + +func showDogStatus(mgr *dog.Manager, name string) error { + d, err := mgr.Get(name) + if err != nil { + return fmt.Errorf("getting dog %s: %w", name, err) + } + + if dogStatusJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(d) + } + + fmt.Printf("Dog: %s\n\n", style.Bold.Render(d.Name)) + fmt.Printf(" State: %s\n", d.State) + if d.Work != "" { + fmt.Printf(" Work: %s\n", d.Work) + } else { + fmt.Printf(" Work: %s\n", style.Dim.Render("(none)")) + } + fmt.Printf(" Path: %s\n", d.Path) + fmt.Printf(" Last Active: %s\n", dogFormatTimeAgo(d.LastActive)) + fmt.Printf(" Created: %s\n", d.CreatedAt.Format("2006-01-02 15:04")) + + if len(d.Worktrees) > 0 { + fmt.Println("\nWorktrees:") + for rigName, path := range d.Worktrees { + // Check if worktree exists + exists := "✓" + if _, err := os.Stat(path); os.IsNotExist(err) { + exists = "✗" + } + fmt.Printf(" %s %s: %s\n", exists, rigName, path) + } + } + + // Check for tmux session + townRoot, _ := workspace.FindFromCwd() + if townRoot != "" { + sessionName := fmt.Sprintf("gt-deacon-%s", name) + tm := tmux.NewTmux() + if has, _ := tm.HasSession(sessionName); has { + fmt.Printf("\nSession: %s (running)\n", sessionName) + } + } + + return nil +} + +func showPackStatus(mgr *dog.Manager) error { + dogs, err := mgr.List() + if err != nil { + return fmt.Errorf("listing dogs: %w", err) + } + + if dogStatusJSON { + type PackStatus struct { + Total int `json:"total"` + Idle int `json:"idle"` + Working int `json:"working"` + KennelDir string `json:"kennel_dir"` + } + + townRoot, _ := workspace.FindFromCwd() + status := PackStatus{ + Total: len(dogs), + KennelDir: filepath.Join(townRoot, "deacon", "dogs"), + } + for _, d := range dogs { + if d.State == dog.StateIdle { + status.Idle++ + } else { + status.Working++ + } + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(status) + } + + fmt.Println(style.Bold.Render("Pack Status")) + fmt.Println() + + if len(dogs) == 0 { + fmt.Println(" No dogs in kennel") + fmt.Println() + fmt.Println(" Use 'gt dog add ' to add a dog") + return nil + } + + idleCount := 0 + workingCount := 0 + for _, d := range dogs { + if d.State == dog.StateIdle { + idleCount++ + } else { + workingCount++ + } + } + + fmt.Printf(" Total: %d\n", len(dogs)) + fmt.Printf(" Idle: %d\n", idleCount) + fmt.Printf(" Working: %d\n", workingCount) + + if idleCount > 0 { + fmt.Println() + fmt.Println(style.Dim.Render(" Ready for work. Use 'gt dog call' to wake.")) + } + + return nil +} + +// dogFormatTimeAgo formats a time as a relative string like "2 hours ago". +func dogFormatTimeAgo(t time.Time) string { + if t.IsZero() { + return "(unknown)" + } + + d := time.Since(t) + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + mins := int(d.Minutes()) + if mins == 1 { + return "1 minute ago" + } + return fmt.Sprintf("%d minutes ago", mins) + case d < 24*time.Hour: + hours := int(d.Hours()) + if hours == 1 { + return "1 hour ago" + } + return fmt.Sprintf("%d hours ago", hours) + default: + days := int(d.Hours() / 24) + if days == 1 { + return "1 day ago" + } + return fmt.Sprintf("%d days ago", days) + } +}