// Package main implements the bd CLI dependency management commands. package main import ( "context" "fmt" "os" "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/utils" ) var depCmd = &cobra.Command{ Use: "dep", Short: "Manage dependencies", } var depAddCmd = &cobra.Command{ Use: "add [issue-id] [depends-on-id]", Short: "Add a dependency", Args: cobra.ExactArgs(2), Run: func(cmd *cobra.Command, args []string) { depType, _ := cmd.Flags().GetString("type") jsonOutput, _ := cmd.Flags().GetBool("json") ctx := context.Background() // Resolve partial IDs first var fromID, toID string if daemonClient != nil { resolveArgs := &rpc.ResolveIDArgs{ID: args[0]} resp, err := daemonClient.ResolveID(resolveArgs) if err != nil { fmt.Fprintf(os.Stderr, "Error resolving issue ID %s: %v\n", args[0], err) os.Exit(1) } fromID = string(resp.Data) resolveArgs = &rpc.ResolveIDArgs{ID: args[1]} resp, err = daemonClient.ResolveID(resolveArgs) if err != nil { fmt.Fprintf(os.Stderr, "Error resolving dependency ID %s: %v\n", args[1], err) os.Exit(1) } toID = string(resp.Data) } else { var err error fromID, err = utils.ResolvePartialID(ctx, store, args[0]) if err != nil { fmt.Fprintf(os.Stderr, "Error resolving issue ID %s: %v\n", args[0], err) os.Exit(1) } toID, err = utils.ResolvePartialID(ctx, store, args[1]) if err != nil { fmt.Fprintf(os.Stderr, "Error resolving dependency ID %s: %v\n", args[1], err) os.Exit(1) } } // If daemon is running, use RPC if daemonClient != nil { depArgs := &rpc.DepAddArgs{ FromID: fromID, ToID: toID, DepType: depType, } resp, err := daemonClient.AddDependency(depArgs) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } if jsonOutput { fmt.Println(string(resp.Data)) return } green := color.New(color.FgGreen).SprintFunc() fmt.Printf("%s Added dependency: %s depends on %s (%s)\n", green("✓"), args[0], args[1], depType) return } // Direct mode dep := &types.Dependency{ IssueID: fromID, DependsOnID: toID, Type: types.DependencyType(depType), } if err := store.AddDependency(ctx, dep, actor); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } // Schedule auto-flush markDirtyAndScheduleFlush() // Check for cycles after adding dependency cycles, err := store.DetectCycles(ctx) if err != nil { fmt.Fprintf(os.Stderr, "Warning: Failed to check for cycles: %v\n", err) } else if len(cycles) > 0 { yellow := color.New(color.FgYellow).SprintFunc() fmt.Fprintf(os.Stderr, "\n%s Warning: Dependency cycle detected!\n", yellow("⚠")) fmt.Fprintf(os.Stderr, "This can hide issues from the ready work list and cause confusion.\n\n") fmt.Fprintf(os.Stderr, "Cycle path:\n") for _, cycle := range cycles { for j, issue := range cycle { if j == 0 { fmt.Fprintf(os.Stderr, " %s", issue.ID) } else { fmt.Fprintf(os.Stderr, " → %s", issue.ID) } } if len(cycle) > 0 { fmt.Fprintf(os.Stderr, " → %s", cycle[0].ID) } fmt.Fprintf(os.Stderr, "\n") } fmt.Fprintf(os.Stderr, "\nRun 'bd dep cycles' for detailed analysis.\n\n") } if jsonOutput { outputJSON(map[string]interface{}{ "status": "added", "issue_id": fromID, "depends_on_id": toID, "type": depType, }) return } green := color.New(color.FgGreen).SprintFunc() fmt.Printf("%s Added dependency: %s depends on %s (%s)\n", green("✓"), fromID, toID, depType) }, } var depRemoveCmd = &cobra.Command{ Use: "remove [issue-id] [depends-on-id]", Short: "Remove a dependency", Args: cobra.ExactArgs(2), Run: func(cmd *cobra.Command, args []string) { jsonOutput, _ := cmd.Flags().GetBool("json") ctx := context.Background() // Resolve partial IDs first var fromID, toID string if daemonClient != nil { resolveArgs := &rpc.ResolveIDArgs{ID: args[0]} resp, err := daemonClient.ResolveID(resolveArgs) if err != nil { fmt.Fprintf(os.Stderr, "Error resolving issue ID %s: %v\n", args[0], err) os.Exit(1) } fromID = string(resp.Data) resolveArgs = &rpc.ResolveIDArgs{ID: args[1]} resp, err = daemonClient.ResolveID(resolveArgs) if err != nil { fmt.Fprintf(os.Stderr, "Error resolving dependency ID %s: %v\n", args[1], err) os.Exit(1) } toID = string(resp.Data) } else { var err error fromID, err = utils.ResolvePartialID(ctx, store, args[0]) if err != nil { fmt.Fprintf(os.Stderr, "Error resolving issue ID %s: %v\n", args[0], err) os.Exit(1) } toID, err = utils.ResolvePartialID(ctx, store, args[1]) if err != nil { fmt.Fprintf(os.Stderr, "Error resolving dependency ID %s: %v\n", args[1], err) os.Exit(1) } } // If daemon is running, use RPC if daemonClient != nil { depArgs := &rpc.DepRemoveArgs{ FromID: fromID, ToID: toID, } resp, err := daemonClient.RemoveDependency(depArgs) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } if jsonOutput { fmt.Println(string(resp.Data)) return } green := color.New(color.FgGreen).SprintFunc() fmt.Printf("%s Removed dependency: %s no longer depends on %s\n", green("✓"), fromID, toID) return } // Direct mode fullFromID := fromID fullToID := toID if err := store.RemoveDependency(ctx, fullFromID, fullToID, actor); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } // Schedule auto-flush markDirtyAndScheduleFlush() if jsonOutput { outputJSON(map[string]interface{}{ "status": "removed", "issue_id": fullFromID, "depends_on_id": fullToID, }) return } green := color.New(color.FgGreen).SprintFunc() fmt.Printf("%s Removed dependency: %s no longer depends on %s\n", green("✓"), fullFromID, fullToID) }, } var depTreeCmd = &cobra.Command{ Use: "tree [issue-id]", Short: "Show dependency tree", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { jsonOutput, _ := cmd.Flags().GetBool("json") ctx := context.Background() // Resolve partial ID first var fullID string if daemonClient != nil { resolveArgs := &rpc.ResolveIDArgs{ID: args[0]} resp, err := daemonClient.ResolveID(resolveArgs) if err != nil { fmt.Fprintf(os.Stderr, "Error resolving issue ID %s: %v\n", args[0], err) os.Exit(1) } fullID = string(resp.Data) } else { var err error fullID, err = utils.ResolvePartialID(ctx, store, args[0]) if err != nil { fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", args[0], err) os.Exit(1) } } // If daemon is running but doesn't support this command, use direct storage if daemonClient != nil && store == nil { var err error store, err = sqlite.New(dbPath) if err != nil { fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err) os.Exit(1) } defer func() { _ = store.Close() }() } showAllPaths, _ := cmd.Flags().GetBool("show-all-paths") maxDepth, _ := cmd.Flags().GetInt("max-depth") reverse, _ := cmd.Flags().GetBool("reverse") if maxDepth < 1 { fmt.Fprintf(os.Stderr, "Error: --max-depth must be >= 1\n") os.Exit(1) } tree, err := store.GetDependencyTree(ctx, fullID, maxDepth, showAllPaths, reverse) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } if jsonOutput { // Always output array, even if empty if tree == nil { tree = []*types.TreeNode{} } outputJSON(tree) return } if len(tree) == 0 { if reverse { fmt.Printf("\n%s has no dependents\n", fullID) } else { fmt.Printf("\n%s has no dependencies\n", fullID) } return } cyan := color.New(color.FgCyan).SprintFunc() if reverse { fmt.Printf("\n%s Dependent tree for %s:\n\n", cyan("🌲"), fullID) } else { fmt.Printf("\n%s Dependency tree for %s:\n\n", cyan("🌲"), fullID) } hasTruncation := false for _, node := range tree { indent := "" for i := 0; i < node.Depth; i++ { indent += " " } line := fmt.Sprintf("%s→ %s: %s [P%d] (%s)", indent, node.ID, node.Title, node.Priority, node.Status) if node.Truncated { line += " … [truncated]" hasTruncation = true } fmt.Println(line) } if hasTruncation { yellow := color.New(color.FgYellow).SprintFunc() fmt.Printf("\n%s Warning: Tree truncated at depth %d (safety limit)\n", yellow("⚠"), maxDepth) } fmt.Println() }, } var depCyclesCmd = &cobra.Command{ Use: "cycles", Short: "Detect dependency cycles", Run: func(cmd *cobra.Command, args []string) { jsonOutput, _ := cmd.Flags().GetBool("json") // If daemon is running but doesn't support this command, use direct storage if daemonClient != nil && store == nil { var err error store, err = sqlite.New(dbPath) if err != nil { fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err) os.Exit(1) } defer func() { _ = store.Close() }() } ctx := context.Background() cycles, err := store.DetectCycles(ctx) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } if jsonOutput { // Always output array, even if empty if cycles == nil { cycles = [][]*types.Issue{} } outputJSON(cycles) return } if len(cycles) == 0 { green := color.New(color.FgGreen).SprintFunc() fmt.Printf("\n%s No dependency cycles detected\n\n", green("✓")) return } red := color.New(color.FgRed).SprintFunc() fmt.Printf("\n%s Found %d dependency cycles:\n\n", red("⚠"), len(cycles)) for i, cycle := range cycles { fmt.Printf("%d. Cycle involving:\n", i+1) for _, issue := range cycle { fmt.Printf(" - %s: %s\n", issue.ID, issue.Title) } fmt.Println() } }, } func init() { depAddCmd.Flags().StringP("type", "t", "blocks", "Dependency type (blocks|related|parent-child|discovered-from)") depAddCmd.Flags().Bool("json", false, "Output JSON format") depRemoveCmd.Flags().Bool("json", false, "Output JSON format") depTreeCmd.Flags().Bool("show-all-paths", false, "Show all paths to nodes (no deduplication for diamond dependencies)") depTreeCmd.Flags().IntP("max-depth", "d", 50, "Maximum tree depth to display (safety limit)") depTreeCmd.Flags().Bool("reverse", false, "Show dependent tree (what was discovered from this) instead of dependency tree (what blocks this)") depTreeCmd.Flags().Bool("json", false, "Output JSON format") depCyclesCmd.Flags().Bool("json", false, "Output JSON format") depCmd.AddCommand(depAddCmd) depCmd.AddCommand(depRemoveCmd) depCmd.AddCommand(depTreeCmd) depCmd.AddCommand(depCyclesCmd) rootCmd.AddCommand(depCmd) }