From 206755be686bdf50a092d05c434bb663d140310d Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Fri, 19 Dec 2025 00:34:52 -0800 Subject: [PATCH] feat(cli): add bd pin/unpin commands (bd-iea) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add pin.go and unpin.go to cmd/bd/ for managing pinned issues. - bd pin sets Pinned=true on an issue - bd unpin sets Pinned=false on an issue Also adds Pinned field support to RPC UpdateArgs for daemon mode. 🤝 Slit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/bd/pin.go | 138 ++++++++++++++++++++++++++++ cmd/bd/unpin.go | 138 ++++++++++++++++++++++++++++ internal/rpc/protocol.go | 2 + internal/rpc/server_issues_epics.go | 4 + 4 files changed, 282 insertions(+) create mode 100644 cmd/bd/pin.go create mode 100644 cmd/bd/unpin.go diff --git a/cmd/bd/pin.go b/cmd/bd/pin.go new file mode 100644 index 00000000..a2b96351 --- /dev/null +++ b/cmd/bd/pin.go @@ -0,0 +1,138 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/fatih/color" + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/rpc" + "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/utils" +) + +var pinCmd = &cobra.Command{ + Use: "pin [id...]", + Short: "Pin one or more issues as persistent context markers", + Long: `Pin issues to mark them as persistent context markers. + +Pinned issues are not work items - they are context beads that should +remain visible and not be cleaned up or closed automatically. + +Examples: + bd pin bd-abc # Pin a single issue + bd pin bd-abc bd-def # Pin multiple issues`, + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + CheckReadonly("pin") + + ctx := rootCtx + + // Resolve partial IDs first + var resolvedIDs []string + if daemonClient != nil { + for _, id := range args { + resolveArgs := &rpc.ResolveIDArgs{ID: id} + resp, err := daemonClient.ResolveID(resolveArgs) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving ID %s: %v\n", id, err) + os.Exit(1) + } + var resolvedID string + if err := json.Unmarshal(resp.Data, &resolvedID); err != nil { + fmt.Fprintf(os.Stderr, "Error unmarshaling resolved ID: %v\n", err) + os.Exit(1) + } + resolvedIDs = append(resolvedIDs, resolvedID) + } + } else { + var err error + resolvedIDs, err = utils.ResolvePartialIDs(ctx, store, args) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + } + + pinnedIssues := []*types.Issue{} + + // If daemon is running, use RPC + if daemonClient != nil { + for _, id := range resolvedIDs { + pinned := true + updateArgs := &rpc.UpdateArgs{ + ID: id, + Pinned: &pinned, + } + + resp, err := daemonClient.Update(updateArgs) + if err != nil { + fmt.Fprintf(os.Stderr, "Error pinning %s: %v\n", id, err) + continue + } + + if jsonOutput { + var issue types.Issue + if err := json.Unmarshal(resp.Data, &issue); err == nil { + pinnedIssues = append(pinnedIssues, &issue) + } + } else { + green := color.New(color.FgGreen).SprintFunc() + fmt.Printf("%s Pinned %s\n", green("📌"), id) + } + } + + if jsonOutput && len(pinnedIssues) > 0 { + outputJSON(pinnedIssues) + } + return + } + + // Fall back to direct storage access + if store == nil { + fmt.Fprintln(os.Stderr, "Error: database not initialized") + os.Exit(1) + } + + for _, id := range args { + fullID, err := utils.ResolvePartialID(ctx, store, id) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", id, err) + continue + } + + updates := map[string]interface{}{ + "pinned": true, + } + + if err := store.UpdateIssue(ctx, fullID, updates, actor); err != nil { + fmt.Fprintf(os.Stderr, "Error pinning %s: %v\n", fullID, err) + continue + } + + if jsonOutput { + issue, _ := store.GetIssue(ctx, fullID) + if issue != nil { + pinnedIssues = append(pinnedIssues, issue) + } + } else { + green := color.New(color.FgGreen).SprintFunc() + fmt.Printf("%s Pinned %s\n", green("📌"), fullID) + } + } + + // Schedule auto-flush if any issues were pinned + if len(args) > 0 { + markDirtyAndScheduleFlush() + } + + if jsonOutput && len(pinnedIssues) > 0 { + outputJSON(pinnedIssues) + } + }, +} + +func init() { + rootCmd.AddCommand(pinCmd) +} diff --git a/cmd/bd/unpin.go b/cmd/bd/unpin.go new file mode 100644 index 00000000..813afff7 --- /dev/null +++ b/cmd/bd/unpin.go @@ -0,0 +1,138 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/fatih/color" + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/rpc" + "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/utils" +) + +var unpinCmd = &cobra.Command{ + Use: "unpin [id...]", + Short: "Unpin one or more issues", + Long: `Unpin issues to remove their persistent context marker status. + +This restores the issue to a normal work item that can be cleaned up +or closed normally. + +Examples: + bd unpin bd-abc # Unpin a single issue + bd unpin bd-abc bd-def # Unpin multiple issues`, + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + CheckReadonly("unpin") + + ctx := rootCtx + + // Resolve partial IDs first + var resolvedIDs []string + if daemonClient != nil { + for _, id := range args { + resolveArgs := &rpc.ResolveIDArgs{ID: id} + resp, err := daemonClient.ResolveID(resolveArgs) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving ID %s: %v\n", id, err) + os.Exit(1) + } + var resolvedID string + if err := json.Unmarshal(resp.Data, &resolvedID); err != nil { + fmt.Fprintf(os.Stderr, "Error unmarshaling resolved ID: %v\n", err) + os.Exit(1) + } + resolvedIDs = append(resolvedIDs, resolvedID) + } + } else { + var err error + resolvedIDs, err = utils.ResolvePartialIDs(ctx, store, args) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + } + + unpinnedIssues := []*types.Issue{} + + // If daemon is running, use RPC + if daemonClient != nil { + for _, id := range resolvedIDs { + pinned := false + updateArgs := &rpc.UpdateArgs{ + ID: id, + Pinned: &pinned, + } + + resp, err := daemonClient.Update(updateArgs) + if err != nil { + fmt.Fprintf(os.Stderr, "Error unpinning %s: %v\n", id, err) + continue + } + + if jsonOutput { + var issue types.Issue + if err := json.Unmarshal(resp.Data, &issue); err == nil { + unpinnedIssues = append(unpinnedIssues, &issue) + } + } else { + yellow := color.New(color.FgYellow).SprintFunc() + fmt.Printf("%s Unpinned %s\n", yellow("📍"), id) + } + } + + if jsonOutput && len(unpinnedIssues) > 0 { + outputJSON(unpinnedIssues) + } + return + } + + // Fall back to direct storage access + if store == nil { + fmt.Fprintln(os.Stderr, "Error: database not initialized") + os.Exit(1) + } + + for _, id := range args { + fullID, err := utils.ResolvePartialID(ctx, store, id) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", id, err) + continue + } + + updates := map[string]interface{}{ + "pinned": false, + } + + if err := store.UpdateIssue(ctx, fullID, updates, actor); err != nil { + fmt.Fprintf(os.Stderr, "Error unpinning %s: %v\n", fullID, err) + continue + } + + if jsonOutput { + issue, _ := store.GetIssue(ctx, fullID) + if issue != nil { + unpinnedIssues = append(unpinnedIssues, issue) + } + } else { + yellow := color.New(color.FgYellow).SprintFunc() + fmt.Printf("%s Unpinned %s\n", yellow("📍"), fullID) + } + } + + // Schedule auto-flush if any issues were unpinned + if len(args) > 0 { + markDirtyAndScheduleFlush() + } + + if jsonOutput && len(unpinnedIssues) > 0 { + outputJSON(unpinnedIssues) + } + }, +} + +func init() { + rootCmd.AddCommand(unpinCmd) +} diff --git a/internal/rpc/protocol.go b/internal/rpc/protocol.go index aae6aed8..8ef707a6 100644 --- a/internal/rpc/protocol.go +++ b/internal/rpc/protocol.go @@ -103,6 +103,8 @@ type UpdateArgs struct { RelatesTo *string `json:"relates_to,omitempty"` // JSON array of related issue IDs DuplicateOf *string `json:"duplicate_of,omitempty"` // Canonical issue ID if duplicate SupersededBy *string `json:"superseded_by,omitempty"` // Replacement issue ID if obsolete + // Pinned field (bd-iea) + Pinned *bool `json:"pinned,omitempty"` // If true, issue is a persistent context marker } // CloseArgs represents arguments for the close operation diff --git a/internal/rpc/server_issues_epics.go b/internal/rpc/server_issues_epics.go index aaeddf51..6bd05e70 100644 --- a/internal/rpc/server_issues_epics.go +++ b/internal/rpc/server_issues_epics.go @@ -97,6 +97,10 @@ func updatesFromArgs(a UpdateArgs) map[string]interface{} { if a.SupersededBy != nil { u["superseded_by"] = *a.SupersededBy } + // Pinned field (bd-iea) + if a.Pinned != nil { + u["pinned"] = *a.Pinned + } return u }