Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
473 changes: 280 additions & 193 deletions docs/CLI_REFERENCE.md

Large diffs are not rendered by default.

16 changes: 12 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
module bmad-automate
module bmaduum

go 1.25.5

Expand All @@ -7,21 +7,30 @@ require (
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
gopkg.in/yaml.v3 v3.0.1
)

require (
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/bubbles v0.21.0 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
Expand All @@ -34,7 +43,6 @@ require (
github.com/subosito/gotenv v1.6.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.28.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
21 changes: 21 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,18 +1,30 @@
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
Expand All @@ -31,8 +43,14 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
Expand Down Expand Up @@ -70,9 +88,12 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
Expand Down
210 changes: 210 additions & 0 deletions internal/cli/story.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
package cli

import (
"context"
"errors"
"fmt"

"github.com/spf13/cobra"

"bmaduum/internal/lifecycle"
"bmaduum/internal/router"
"bmaduum/internal/tui"
)

func newStoryCommand(app *App) *cobra.Command {
var dryRun bool
var autoRetry bool
var useTUI bool

cmd := &cobra.Command{
Use: "story <story-key> [story-key...]",
Short: "Run the full story lifecycle to completion",
Long: `Run the complete lifecycle for one or more stories from their current status to done.

Each story is run to completion before moving to the next.

For each story, executes all remaining workflows based on its current status:
- backlog → create-story → dev-story → code-review → git-commit → done
- ready-for-dev → dev-story → code-review → git-commit → done
- in-progress → dev-story → code-review → git-commit → done
- review → code-review → git-commit → done
- done → skipped (story already complete)

The command stops on the first failure. Done stories are skipped and do not cause failure.
Status is updated in sprint-status.yaml after each successful workflow.

Use --dry-run to preview workflows without executing them.
Use --auto-retry to automatically retry on rate limit errors.
Use --tui to enable the interactive TUI mode.

Examples:
bmaduum story 6-1
bmaduum story 6-1 6-2 6-3
bmaduum story --tui 6-1`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
storyKeys := args

// Create lifecycle executor with app dependencies
executor := lifecycle.NewExecutor(app.Runner, app.StatusReader, app.StatusWriter)

// Handle dry-run mode
if dryRun {
return runStoryDryRun(cmd, app, executor, storyKeys)
}

// Handle TUI mode (only for single story)
if useTUI {
if len(storyKeys) > 1 {
return fmt.Errorf("TUI mode only supports single story execution")
}
return executeStoryTUI(ctx, app, executor, storyKeys[0])
}

// Execute full lifecycle for each story in order
for i, storyKey := range storyKeys {
// Show story progress for multiple stories
if len(storyKeys) > 1 {
fmt.Printf("─── Story %d of %d: %s\n", i+1, len(storyKeys), storyKey)
}

err := executeWithRetry(ctx, executor, storyKey, autoRetry, 10, func(stepIndex, totalSteps int, workflow string) {
app.Printer.StepStart(stepIndex, totalSteps, workflow)
})
if err != nil {
cmd.SilenceUsage = true
if errors.Is(err, router.ErrStoryComplete) {
fmt.Printf("Story %s is already complete, skipping\n", storyKey)
continue
}
fmt.Printf("Error running lifecycle for story %s: %v\n", storyKey, err)
return NewExitError(1)
}

// Show completion message
if len(storyKeys) > 1 {
fmt.Printf("Story %s completed successfully\n\n", storyKey)
}
}

if len(storyKeys) > 1 {
fmt.Printf("All %d stories processed\n", len(storyKeys))
}

return nil
},
}

cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Preview workflows without executing them")
cmd.Flags().BoolVar(&autoRetry, "auto-retry", false, "Automatically retry on rate limit errors")
cmd.Flags().BoolVar(&useTUI, "tui", false, "Enable interactive TUI mode (single story only)")

return cmd
}

func runStoryDryRun(cmd *cobra.Command, app *App, executor *lifecycle.Executor, storyKeys []string) error {
// Single story dry-run - simpler output
if len(storyKeys) == 1 {
storyKey := storyKeys[0]
steps, err := executor.GetSteps(storyKey)
if err != nil {
cmd.SilenceUsage = true
if errors.Is(err, router.ErrStoryComplete) {
fmt.Printf("Story is already complete, no workflows to run\n")
return nil
}
fmt.Printf("Error: %v\n", err)
return NewExitError(1)
}

fmt.Printf("Dry run for story %s:\n", storyKey)
for i, step := range steps {
modelInfo := ""
model := app.Config.GetModel(step.Workflow)
if model != "" {
modelInfo = fmt.Sprintf(" (%s)", model)
}
fmt.Printf(" %d. %s%s → %s\n", i+1, step.Workflow, modelInfo, step.NextStatus)
}
return nil
}

// Multiple stories dry-run - detailed output
fmt.Printf("Dry run for %d stories:\n", len(storyKeys))

totalWorkflows := 0
storiesWithWork := 0
storiesComplete := 0

for _, storyKey := range storyKeys {
fmt.Println()
fmt.Printf("Story %s:\n", storyKey)

steps, err := executor.GetSteps(storyKey)
if err != nil {
if errors.Is(err, router.ErrStoryComplete) {
fmt.Printf(" (already complete)\n")
storiesComplete++
continue
}
cmd.SilenceUsage = true
fmt.Printf(" Error: %v\n", err)
return NewExitError(1)
}

for i, step := range steps {
modelInfo := ""
model := app.Config.GetModel(step.Workflow)
if model != "" {
modelInfo = fmt.Sprintf(" (%s)", model)
}
fmt.Printf(" %d. %s%s → %s\n", i+1, step.Workflow, modelInfo, step.NextStatus)
}
totalWorkflows += len(steps)
storiesWithWork++
}

fmt.Println()
if storiesComplete > 0 {
fmt.Printf("Total: %d workflows across %d stories (%d already complete)\n", totalWorkflows, storiesWithWork, storiesComplete)
} else {
fmt.Printf("Total: %d workflows across %d stories\n", totalWorkflows, storiesWithWork)
}

return nil
}

// executeStoryTUI runs a story lifecycle using the TUI interface.
func executeStoryTUI(ctx context.Context, app *App, executor *lifecycle.Executor, storyKey string) error {
// Get the steps for this story
steps, err := executor.GetSteps(storyKey)
if err != nil {
if errors.Is(err, router.ErrStoryComplete) {
fmt.Printf("Story %s is already complete\n", storyKey)
return nil
}
return err
}

// Convert lifecycle steps to TUI steps
tuiSteps := make([]tui.StepInfo, len(steps))
for i, step := range steps {
tuiSteps[i] = tui.StepInfo{
Name: step.Workflow,
StoryKey: storyKey,
NextStatus: string(step.NextStatus),
}
}

// Create TUI runner and execute
tuiRunner := tui.NewRunner(app.Executor, app.Config)
exitCode := tuiRunner.RunMultiStep(ctx, tuiSteps, storyKey)

if exitCode != 0 {
return NewExitError(exitCode)
}

return nil
}
54 changes: 54 additions & 0 deletions internal/tui/events.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package tui

import (
"time"

tea "github.com/charmbracelet/bubbletea"
)

// TextEvent indicates new text content has arrived.
type TextEvent struct{ Text string }

// ToolUseEvent indicates a tool is being invoked.
type ToolUseEvent struct {
Name string
Description string
Command string
FilePath string
}

// ToolResultEvent contains the result of a tool execution.
type ToolResultEvent struct {
Stdout string
Stderr string
}

// SessionStartEvent indicates a Claude session has started.
type SessionStartEvent struct{}

// StepStartMsg indicates a new workflow step has started.
type StepStartMsg struct {
Step int
Total int
StepName string
StoryKey string
}

// StepCompleteMsg indicates the current step has completed.
type StepCompleteMsg struct {
Success bool
Duration time.Duration
}

// CompleteMsg indicates the entire TUI workflow is complete.
type CompleteMsg struct {
ExitCode int
}

// SpinnerUpdateMsg updates the spinner state.
type SpinnerUpdateMsg struct {
tea.Msg
}

// SessionCompleteEvent indicates a Claude session has completed.
type SessionCompleteEvent struct{}
Loading