Parse $EDITOR value to handle editors that need flags like "zeditor --wait" or "code --wait". Previously the entire string was treated as the executable name. Fixes #987 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> Executed-By: beads/crew/dave Rig: beads Role: crew
213 lines
5.8 KiB
Go
213 lines
5.8 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/beads/internal/rpc"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
"github.com/steveyegge/beads/internal/ui"
|
|
"github.com/steveyegge/beads/internal/utils"
|
|
)
|
|
|
|
var editCmd = &cobra.Command{
|
|
Use: "edit [id]",
|
|
GroupID: "issues",
|
|
Short: "Edit an issue field in $EDITOR",
|
|
Long: `Edit an issue field using your configured $EDITOR.
|
|
|
|
By default, edits the description. Use flags to edit other fields.
|
|
|
|
Examples:
|
|
bd edit bd-42 # Edit description
|
|
bd edit bd-42 --title # Edit title
|
|
bd edit bd-42 --design # Edit design notes
|
|
bd edit bd-42 --notes # Edit notes
|
|
bd edit bd-42 --acceptance # Edit acceptance criteria`,
|
|
Args: cobra.ExactArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
CheckReadonly("edit")
|
|
id := args[0]
|
|
ctx := rootCtx
|
|
|
|
// Resolve partial ID if in direct mode
|
|
if daemonClient == nil {
|
|
fullID, err := utils.ResolvePartialID(ctx, store, id)
|
|
if err != nil {
|
|
FatalErrorRespectJSON("resolving %s: %v", id, err)
|
|
}
|
|
id = fullID
|
|
}
|
|
|
|
// Determine which field to edit
|
|
fieldToEdit := "description"
|
|
if cmd.Flags().Changed("title") {
|
|
fieldToEdit = "title"
|
|
} else if cmd.Flags().Changed("design") {
|
|
fieldToEdit = "design"
|
|
} else if cmd.Flags().Changed("notes") {
|
|
fieldToEdit = "notes"
|
|
} else if cmd.Flags().Changed("acceptance") {
|
|
fieldToEdit = "acceptance_criteria"
|
|
}
|
|
|
|
// Get the editor from environment
|
|
editor := os.Getenv("EDITOR")
|
|
if editor == "" {
|
|
editor = os.Getenv("VISUAL")
|
|
}
|
|
if editor == "" {
|
|
// Try common defaults
|
|
for _, defaultEditor := range []string{"vim", "vi", "nano", "emacs"} {
|
|
if _, err := exec.LookPath(defaultEditor); err == nil {
|
|
editor = defaultEditor
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if editor == "" {
|
|
FatalErrorRespectJSON("no editor found. Set $EDITOR or $VISUAL environment variable")
|
|
}
|
|
|
|
// Get the current issue
|
|
var issue *types.Issue
|
|
var err error
|
|
|
|
if daemonClient != nil {
|
|
// Daemon mode
|
|
showArgs := &rpc.ShowArgs{ID: id}
|
|
resp, err := daemonClient.Show(showArgs)
|
|
if err != nil {
|
|
FatalErrorRespectJSON("fetching issue %s: %v", id, err)
|
|
}
|
|
|
|
issue = &types.Issue{}
|
|
if err := json.Unmarshal(resp.Data, issue); err != nil {
|
|
FatalErrorRespectJSON("parsing issue data: %v", err)
|
|
}
|
|
} else {
|
|
// Direct mode
|
|
issue, err = store.GetIssue(ctx, id)
|
|
if err != nil {
|
|
FatalErrorRespectJSON("fetching issue %s: %v", id, err)
|
|
}
|
|
if issue == nil {
|
|
FatalErrorRespectJSON("issue %s not found", id)
|
|
}
|
|
}
|
|
|
|
// Get the current field value
|
|
var currentValue string
|
|
switch fieldToEdit {
|
|
case "title":
|
|
currentValue = issue.Title
|
|
case "description":
|
|
currentValue = issue.Description
|
|
case "design":
|
|
currentValue = issue.Design
|
|
case "notes":
|
|
currentValue = issue.Notes
|
|
case "acceptance_criteria":
|
|
currentValue = issue.AcceptanceCriteria
|
|
}
|
|
|
|
// Create a temporary file with the current value
|
|
tmpFile, err := os.CreateTemp("", fmt.Sprintf("bd-edit-%s-*.txt", fieldToEdit))
|
|
if err != nil {
|
|
FatalErrorRespectJSON("creating temp file: %v", err)
|
|
}
|
|
tmpPath := tmpFile.Name()
|
|
defer func() { _ = os.Remove(tmpPath) }()
|
|
|
|
// Write current value to temp file
|
|
if _, err := tmpFile.WriteString(currentValue); err != nil {
|
|
_ = tmpFile.Close()
|
|
FatalErrorRespectJSON("writing to temp file: %v", err)
|
|
}
|
|
_ = tmpFile.Close()
|
|
|
|
// Open the editor - parse command and args (handles "vim -w" or "zeditor --wait")
|
|
editorParts := strings.Fields(editor)
|
|
editorArgs := append(editorParts[1:], tmpPath)
|
|
editorCmd := exec.Command(editorParts[0], editorArgs...) //nolint:gosec // G204: editor from trusted $EDITOR/$VISUAL env or known defaults
|
|
editorCmd.Stdin = os.Stdin
|
|
editorCmd.Stdout = os.Stdout
|
|
editorCmd.Stderr = os.Stderr
|
|
|
|
if err := editorCmd.Run(); err != nil {
|
|
FatalErrorRespectJSON("running editor: %v", err)
|
|
}
|
|
|
|
// Read the edited content
|
|
// #nosec G304 -- tmpPath was created earlier in this function
|
|
editedContent, err := os.ReadFile(tmpPath)
|
|
if err != nil {
|
|
FatalErrorRespectJSON("reading edited file: %v", err)
|
|
}
|
|
|
|
newValue := string(editedContent)
|
|
|
|
// Check if the value changed
|
|
if newValue == currentValue {
|
|
fmt.Println("No changes made")
|
|
return
|
|
}
|
|
|
|
// Validate title if editing title
|
|
if fieldToEdit == "title" && strings.TrimSpace(newValue) == "" {
|
|
FatalErrorRespectJSON("title cannot be empty")
|
|
}
|
|
|
|
// Update the issue
|
|
updates := map[string]interface{}{
|
|
fieldToEdit: newValue,
|
|
}
|
|
|
|
if daemonClient != nil {
|
|
// Daemon mode
|
|
updateArgs := &rpc.UpdateArgs{ID: id}
|
|
|
|
switch fieldToEdit {
|
|
case "title":
|
|
updateArgs.Title = &newValue
|
|
case "description":
|
|
updateArgs.Description = &newValue
|
|
case "design":
|
|
updateArgs.Design = &newValue
|
|
case "notes":
|
|
updateArgs.Notes = &newValue
|
|
case "acceptance_criteria":
|
|
updateArgs.AcceptanceCriteria = &newValue
|
|
}
|
|
|
|
_, err := daemonClient.Update(updateArgs)
|
|
if err != nil {
|
|
FatalErrorRespectJSON("updating issue: %v", err)
|
|
}
|
|
} else {
|
|
// Direct mode
|
|
if err := store.UpdateIssue(ctx, id, updates, actor); err != nil {
|
|
FatalErrorRespectJSON("updating issue: %v", err)
|
|
}
|
|
markDirtyAndScheduleFlush()
|
|
}
|
|
|
|
fieldName := strings.ReplaceAll(fieldToEdit, "_", " ")
|
|
fmt.Printf("%s Updated %s for issue: %s\n", ui.RenderPass("✓"), fieldName, id)
|
|
},
|
|
}
|
|
|
|
func init() {
|
|
editCmd.Flags().Bool("title", false, "Edit the title")
|
|
editCmd.Flags().Bool("description", false, "Edit the description (default)")
|
|
editCmd.Flags().Bool("design", false, "Edit the design notes")
|
|
editCmd.Flags().Bool("notes", false, "Edit the notes")
|
|
editCmd.Flags().Bool("acceptance", false, "Edit the acceptance criteria")
|
|
editCmd.ValidArgsFunction = issueIDCompletion
|
|
rootCmd.AddCommand(editCmd)
|
|
}
|