feat(cmd): add desire-path commands for agent ergonomics

- gt hook --clear: alias for 'gt unhook' (gt-eod2iv)
- gt close: wrapper for 'bd close' (gt-msak6o)
- gt bead move: move beads between repos (gt-dzdbr7)

These commands were natural guesses that agents tried but didn't exist.
Following the desire-paths approach to improve agent ergonomics.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gastown/crew/george
2026-01-16 15:28:45 -08:00
committed by Steve Yegge
parent 9b34b6bfec
commit 22a24c5648
3 changed files with 198 additions and 1 deletions

148
internal/cmd/bead.go Normal file
View File

@@ -0,0 +1,148 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/style"
)
var beadCmd = &cobra.Command{
Use: "bead",
GroupID: GroupWork,
Short: "Bead management utilities",
Long: `Utilities for managing beads across repositories.`,
}
var beadMoveCmd = &cobra.Command{
Use: "move <bead-id> <target-prefix>",
Short: "Move a bead to a different repository",
Long: `Move a bead from one repository to another.
This creates a copy of the bead in the target repository (with the new prefix)
and closes the source bead with a reference to the new location.
The target prefix determines which repository receives the bead.
Common prefixes: gt- (gastown), bd- (beads), hq- (headquarters)
Examples:
gt bead move gt-abc123 bd- # Move gt-abc123 to beads repo as bd-*
gt bead move hq-xyz bd- # Move hq-xyz to beads repo
gt bead move bd-123 gt- # Move bd-123 to gastown repo`,
Args: cobra.ExactArgs(2),
RunE: runBeadMove,
}
var beadMoveDryRun bool
func init() {
beadMoveCmd.Flags().BoolVarP(&beadMoveDryRun, "dry-run", "n", false, "Show what would be done")
beadCmd.AddCommand(beadMoveCmd)
rootCmd.AddCommand(beadCmd)
}
// moveBeadInfo holds the essential fields we need to copy when moving beads
type moveBeadInfo struct {
ID string `json:"id"`
Title string `json:"title"`
Type string `json:"issue_type"`
Priority int `json:"priority"`
Description string `json:"description"`
Labels []string `json:"labels"`
Assignee string `json:"assignee"`
Status string `json:"status"`
}
func runBeadMove(cmd *cobra.Command, args []string) error {
sourceID := args[0]
targetPrefix := args[1]
// Normalize prefix (ensure it ends with -)
if !strings.HasSuffix(targetPrefix, "-") {
targetPrefix = targetPrefix + "-"
}
// Get source bead details
showCmd := exec.Command("bd", "show", sourceID, "--json")
output, err := showCmd.Output()
if err != nil {
return fmt.Errorf("getting bead %s: %w", sourceID, err)
}
// bd show --json returns an array
var sources []moveBeadInfo
if err := json.Unmarshal(output, &sources); err != nil {
return fmt.Errorf("parsing bead data: %w", err)
}
if len(sources) == 0 {
return fmt.Errorf("bead %s not found", sourceID)
}
source := sources[0]
// Don't move closed beads
if source.Status == "closed" {
return fmt.Errorf("cannot move closed bead %s", sourceID)
}
fmt.Printf("%s Moving %s to %s...\n", style.Bold.Render("→"), sourceID, targetPrefix)
fmt.Printf(" Title: %s\n", source.Title)
fmt.Printf(" Type: %s\n", source.Type)
if beadMoveDryRun {
fmt.Printf("\nDry run - would:\n")
fmt.Printf(" 1. Create new bead with prefix %s\n", targetPrefix)
fmt.Printf(" 2. Close %s with reference to new bead\n", sourceID)
return nil
}
// Build create command for target
createArgs := []string{
"create",
"--prefix", targetPrefix,
"--title", source.Title,
"--type", source.Type,
"--priority", fmt.Sprintf("%d", source.Priority),
"--silent", // Only output the ID
}
if source.Description != "" {
createArgs = append(createArgs, "--description", source.Description)
}
if source.Assignee != "" {
createArgs = append(createArgs, "--assignee", source.Assignee)
}
for _, label := range source.Labels {
createArgs = append(createArgs, "--label", label)
}
// Create the new bead
createCmd := exec.Command("bd", createArgs...)
createCmd.Stderr = os.Stderr
newIDBytes, err := createCmd.Output()
if err != nil {
return fmt.Errorf("creating new bead: %w", err)
}
newID := strings.TrimSpace(string(newIDBytes))
fmt.Printf("%s Created %s\n", style.Bold.Render("✓"), newID)
// Close the source bead with reference
closeReason := fmt.Sprintf("Moved to %s", newID)
closeCmd := exec.Command("bd", "close", sourceID, "--reason", closeReason)
closeCmd.Stderr = os.Stderr
if err := closeCmd.Run(); err != nil {
// Try to clean up the new bead if close fails
fmt.Fprintf(os.Stderr, "Warning: failed to close source bead: %v\n", err)
fmt.Fprintf(os.Stderr, "New bead %s was created but source %s remains open\n", newID, sourceID)
return err
}
fmt.Printf("%s Closed %s (moved to %s)\n", style.Bold.Render("✓"), sourceID, newID)
fmt.Printf("\nBead moved: %s → %s\n", sourceID, newID)
return nil
}

40
internal/cmd/close.go Normal file
View File

@@ -0,0 +1,40 @@
package cmd
import (
"os"
"os/exec"
"github.com/spf13/cobra"
)
var closeCmd = &cobra.Command{
Use: "close [bead-id...]",
GroupID: GroupWork,
Short: "Close one or more beads",
Long: `Close one or more beads (wrapper for 'bd close').
This is a convenience command that passes through to 'bd close' with
all arguments and flags preserved.
Examples:
gt close gt-abc # Close bead gt-abc
gt close gt-abc gt-def # Close multiple beads
gt close --reason "Done" # Close with reason
gt close --force # Force close pinned beads`,
DisableFlagParsing: true, // Pass all flags through to bd close
RunE: runClose,
}
func init() {
rootCmd.AddCommand(closeCmd)
}
func runClose(cmd *cobra.Command, args []string) error {
// Build bd close command with all args passed through
bdArgs := append([]string{"close"}, args...)
bdCmd := exec.Command("bd", bdArgs...)
bdCmd.Stdin = os.Stdin
bdCmd.Stdout = os.Stdout
bdCmd.Stderr = os.Stderr
return bdCmd.Run()
}

View File

@@ -89,6 +89,7 @@ var (
hookMessage string
hookDryRun bool
hookForce bool
hookClear bool
)
func init() {
@@ -97,6 +98,7 @@ func init() {
hookCmd.Flags().StringVarP(&hookMessage, "message", "m", "", "Message for handoff mail (optional)")
hookCmd.Flags().BoolVarP(&hookDryRun, "dry-run", "n", false, "Show what would be done")
hookCmd.Flags().BoolVarP(&hookForce, "force", "f", false, "Replace existing incomplete hooked bead")
hookCmd.Flags().BoolVar(&hookClear, "clear", false, "Clear your hook (alias for 'gt unhook')")
// --json flag for status output (used when no args, i.e., gt hook --json)
hookCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON (for status)")
@@ -108,8 +110,15 @@ func init() {
rootCmd.AddCommand(hookCmd)
}
// runHookOrStatus dispatches to status or hook based on args
// runHookOrStatus dispatches to status, clear, or hook based on args/flags
func runHookOrStatus(cmd *cobra.Command, args []string) error {
// --clear flag is alias for 'gt unhook'
if hookClear {
// Pass through dry-run and force flags
unslingDryRun = hookDryRun
unslingForce = hookForce
return runUnsling(cmd, args)
}
if len(args) == 0 {
// No args - show status
return runMoleculeStatus(cmd, args)