From 568ae8ebdd4767f89446d84856c3150c8c468ba3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 23:40:10 +0000 Subject: [PATCH 1/5] feat: add `tasklin add` CLI command for creating tickets Supports --label (-l, repeatable) and --status (-s) flags; prints the new ticket number and title as confirmation. Labels are merged into labels.yaml for TUI autocomplete. https://claude.ai/code/session_01BYNzLwixHZvfrTxgxd6Bh3 --- README.md | 25 +++++++++- cmd/add.go | 112 +++++++++++++++++++++++++++++++++++++++++++ docs/architecture.md | 1 + 3 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 cmd/add.go diff --git a/README.md b/README.md index 87711c8..60027f2 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,30 @@ You will be prompted to: This creates a `.todo/` folder with `config.yaml` and an empty `tickets.yaml`. -### 2. Open the TUI +### 2. Create tickets from the command line + +Use `tasklin add` to create tickets without opening the TUI: + +```sh +tasklin add "Fix login bug" +tasklin add "Add dark mode" -l ui -l frontend +tasklin add "Deploy to staging" -s "In Progress" +``` + +Prints the new ticket number and title on success: + +``` +#42 Fix login bug +``` + +**Flags:** + +| Flag | Short | Description | +|---|---|---| +| `--label` | `-l` | Label/tag to attach (repeatable) | +| `--status` | `-s` | Initial status (defaults to first configured status) | + +### 3. Open the TUI Run `tasklin` with no arguments to open the kanban board: diff --git a/cmd/add.go b/cmd/add.go new file mode 100644 index 0000000..82bff3f --- /dev/null +++ b/cmd/add.go @@ -0,0 +1,112 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/frankcruz/tasklin/internal/model" + "github.com/frankcruz/tasklin/internal/store" + "github.com/spf13/cobra" +) + +var addLabels []string +var addStatus string + +var addCmd = &cobra.Command{ + Use: "add ", + Short: "Create a new ticket", + Long: "Create a new ticket with an optional status and labels.", + Args: cobra.MinimumNArgs(1), + RunE: runAdd, +} + +func runAdd(cmd *cobra.Command, args []string) error { + title := strings.Join(args, " ") + + cwd, err := os.Getwd() + if err != nil { + return err + } + s := store.New(cwd) + if !s.Initialised() { + return fmt.Errorf(".todo/ not found — run 'tasklin init' first") + } + + cfg, err := s.ReadConfig() + if err != nil { + return err + } + + statuses := store.SortedStatuses(cfg.Statuses) + if len(statuses) == 0 { + return fmt.Errorf("no statuses configured") + } + + status := statuses[0].Name + if addStatus != "" { + found := false + for _, st := range statuses { + if strings.EqualFold(st.Name, addStatus) { + status = st.Name + found = true + break + } + } + if !found { + return fmt.Errorf("unknown status %q", addStatus) + } + } + + id, err := s.NextID() + if err != nil { + return err + } + + ticket := model.Ticket{ + ID: id, + Title: title, + Status: status, + Labels: addLabels, + CreatedAt: time.Now().UTC(), + } + + tickets, err := s.ReadTickets() + if err != nil { + return err + } + tickets = append(tickets, ticket) + if err := s.WriteTickets(tickets); err != nil { + return err + } + + if len(addLabels) > 0 { + known, err := s.ReadLabels() + if err != nil { + return err + } + existing := make(map[string]bool, len(known)) + for _, l := range known { + existing[l] = true + } + for _, l := range addLabels { + if !existing[l] { + known = append(known, l) + existing[l] = true + } + } + if err := s.WriteLabels(known); err != nil { + return err + } + } + + fmt.Printf("#%d %s\n", id, title) + return nil +} + +func init() { + addCmd.Flags().StringArrayVarP(&addLabels, "label", "l", nil, "label to attach (repeatable)") + addCmd.Flags().StringVarP(&addStatus, "status", "s", "", "initial status (defaults to first configured status)") + rootCmd.AddCommand(addCmd) +} diff --git a/docs/architecture.md b/docs/architecture.md index b0ab1be..b68c1bc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -69,6 +69,7 @@ The binary is structured as a thin `cobra` CLI wrapper around a self-contained B |---|---| | `root.go` | Cobra root command; detects if `.todo/` exists; calls `tui.Run()` | | `init.go` | `tasklin init` interactive setup wizard; calls `store.Init()` and `hooks` package | +| `add.go` | `tasklin add <title>` — creates a ticket from the CLI; supports `--label` and `--status` flags | | `transition.go` | `tasklin _transition <id> <status>` — internal command used by git hooks only | ### `internal/model/` From 97a00543342b4e277195a462493ff6574a25cd0b Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 14 May 2026 23:56:38 +0000 Subject: [PATCH 2/5] feat: add `tasklin move` CLI command for status transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Takes a ticket ID and target status; resolves status case-insensitively and is a no-op when the ticket is already in the target status. Prints `#<id> → <status>` on success and records a Transition entry. https://claude.ai/code/session_01BYNzLwixHZvfrTxgxd6Bh3 --- README.md | 11 +++++- cmd/move.go | 87 ++++++++++++++++++++++++++++++++++++++++++++ docs/architecture.md | 1 + 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 cmd/move.go diff --git a/README.md b/README.md index 60027f2..b713bbf 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,16 @@ Prints the new ticket number and title on success: | `--label` | `-l` | Label/tag to attach (repeatable) | | `--status` | `-s` | Initial status (defaults to first configured status) | -### 3. Open the TUI +### 3. Move a ticket from the command line + +```sh +tasklin move 42 "In Progress" +tasklin move 42 done # case-insensitive +``` + +Prints `#<id> → <status>` on success. Does nothing if the ticket is already in the target status. + +### 4. Open the TUI Run `tasklin` with no arguments to open the kanban board: diff --git a/cmd/move.go b/cmd/move.go new file mode 100644 index 0000000..00a0899 --- /dev/null +++ b/cmd/move.go @@ -0,0 +1,87 @@ +package cmd + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/frankcruz/tasklin/internal/model" + "github.com/frankcruz/tasklin/internal/store" + "github.com/spf13/cobra" +) + +var moveCmd = &cobra.Command{ + Use: "move <ticket-id> <status>", + Short: "Move a ticket to a different status", + Args: cobra.ExactArgs(2), + RunE: runMove, +} + +func runMove(cmd *cobra.Command, args []string) error { + ticketID, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid ticket id: %s", args[0]) + } + targetStatus := args[1] + + cwd, err := os.Getwd() + if err != nil { + return err + } + s := store.New(cwd) + if !s.Initialised() { + return fmt.Errorf(".todo/ not found — run 'tasklin init' first") + } + + cfg, err := s.ReadConfig() + if err != nil { + return err + } + + resolved := "" + for _, st := range cfg.Statuses { + if strings.EqualFold(st.Name, targetStatus) { + resolved = st.Name + break + } + } + if resolved == "" { + return fmt.Errorf("unknown status %q", targetStatus) + } + + tickets, err := s.ReadTickets() + if err != nil { + return err + } + + found := false + for i, t := range tickets { + if t.ID != ticketID { + continue + } + found = true + if t.Status == resolved { + return nil + } + tr := model.Transition{From: t.Status, To: resolved, At: time.Now().UTC()} + tickets[i].Status = resolved + tickets[i].Transitions = append(tickets[i].Transitions, tr) + break + } + if !found { + return fmt.Errorf("ticket %d not found", ticketID) + } + + if err := s.WriteTickets(tickets); err != nil { + return err + } + + fmt.Printf("#%d → %s\n", ticketID, resolved) + return nil +} + +func init() { + rootCmd.AddCommand(moveCmd) +} diff --git a/docs/architecture.md b/docs/architecture.md index b68c1bc..5511ff0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -70,6 +70,7 @@ The binary is structured as a thin `cobra` CLI wrapper around a self-contained B | `root.go` | Cobra root command; detects if `.todo/` exists; calls `tui.Run()` | | `init.go` | `tasklin init` interactive setup wizard; calls `store.Init()` and `hooks` package | | `add.go` | `tasklin add <title>` — creates a ticket from the CLI; supports `--label` and `--status` flags | +| `move.go` | `tasklin move <id> <status>` — moves a ticket to a new status; no-op if already there | | `transition.go` | `tasklin _transition <id> <status>` — internal command used by git hooks only | ### `internal/model/` From bdbec89348d59267c63e09b8c5e82797a2ceb1e8 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 14 May 2026 23:58:13 +0000 Subject: [PATCH 3/5] feat: add `tasklin delete` CLI command Removes a ticket from tickets.yaml and archives it to deleted.yaml so IDs are never reused. Prints `#<id> <title> deleted` on success. https://claude.ai/code/session_01BYNzLwixHZvfrTxgxd6Bh3 --- README.md | 10 +++++- cmd/delete.go | 72 ++++++++++++++++++++++++++++++++++++++++++++ docs/architecture.md | 1 + 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 cmd/delete.go diff --git a/README.md b/README.md index b713bbf..b9e9ffa 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,15 @@ tasklin move 42 done # case-insensitive Prints `#<id> → <status>` on success. Does nothing if the ticket is already in the target status. -### 4. Open the TUI +### 4. Delete a ticket from the command line + +```sh +tasklin delete 42 +``` + +Prints `#<id> <title> deleted` on success and archives the ticket to `deleted.yaml` so IDs are never reused. + +### 5. Open the TUI Run `tasklin` with no arguments to open the kanban board: diff --git a/cmd/delete.go b/cmd/delete.go new file mode 100644 index 0000000..07e73ca --- /dev/null +++ b/cmd/delete.go @@ -0,0 +1,72 @@ +package cmd + +import ( + "fmt" + "os" + "strconv" + + "github.com/frankcruz/tasklin/internal/store" + "github.com/spf13/cobra" +) + +var deleteCmd = &cobra.Command{ + Use: "delete <ticket-id>", + Short: "Delete a ticket", + Args: cobra.ExactArgs(1), + RunE: runDelete, +} + +func runDelete(cmd *cobra.Command, args []string) error { + ticketID, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid ticket id: %s", args[0]) + } + + cwd, err := os.Getwd() + if err != nil { + return err + } + s := store.New(cwd) + if !s.Initialised() { + return fmt.Errorf(".todo/ not found — run 'tasklin init' first") + } + + tickets, err := s.ReadTickets() + if err != nil { + return err + } + + idx := -1 + for i, t := range tickets { + if t.ID == ticketID { + idx = i + break + } + } + if idx == -1 { + return fmt.Errorf("ticket %d not found", ticketID) + } + + deleted, err := s.ReadDeleted() + if err != nil { + return err + } + + title := tickets[idx].Title + deleted = append(deleted, tickets[idx]) + tickets = append(tickets[:idx], tickets[idx+1:]...) + + if err := s.WriteTickets(tickets); err != nil { + return err + } + if err := s.WriteDeleted(deleted); err != nil { + return err + } + + fmt.Printf("#%d %s deleted\n", ticketID, title) + return nil +} + +func init() { + rootCmd.AddCommand(deleteCmd) +} diff --git a/docs/architecture.md b/docs/architecture.md index 5511ff0..d4d103c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -71,6 +71,7 @@ The binary is structured as a thin `cobra` CLI wrapper around a self-contained B | `init.go` | `tasklin init` interactive setup wizard; calls `store.Init()` and `hooks` package | | `add.go` | `tasklin add <title>` — creates a ticket from the CLI; supports `--label` and `--status` flags | | `move.go` | `tasklin move <id> <status>` — moves a ticket to a new status; no-op if already there | +| `delete.go` | `tasklin delete <id>` — removes a ticket from tickets.yaml and archives it to deleted.yaml | | `transition.go` | `tasklin _transition <id> <status>` — internal command used by git hooks only | ### `internal/model/` From d14de6c67fe52007f632e1e70caacc46d84bff84 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 15 May 2026 00:06:19 +0000 Subject: [PATCH 4/5] feat: add `tasklin update` CLI command Supports --title (-t), --add-label (-l, repeatable), and --remove-label (-r, repeatable). Prints a per-field change summary on success; reports no-op when nothing actually changed; errors if no flags are provided. New labels are merged into labels.yaml for TUI autocomplete. https://claude.ai/code/session_01BYNzLwixHZvfrTxgxd6Bh3 --- README.md | 27 +++++++- cmd/update.go | 152 +++++++++++++++++++++++++++++++++++++++++++ docs/architecture.md | 1 + 3 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 cmd/update.go diff --git a/README.md b/README.md index b9e9ffa..fc213de 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,32 @@ tasklin delete 42 Prints `#<id> <title> deleted` on success and archives the ticket to `deleted.yaml` so IDs are never reused. -### 5. Open the TUI +### 5. Update a ticket from the command line + +```sh +tasklin update 42 --title "New title" +tasklin update 42 -l backend -l api # add labels +tasklin update 42 -r backend # remove a label +tasklin update 42 -t "New title" -l api -r old +``` + +Prints the ticket header followed by a line per change: + +``` +#42 New title + title: "Old title" → "New title" + labels: +api, -old +``` + +**Flags:** + +| Flag | Short | Description | +|---|---|---| +| `--title` | `-t` | New title | +| `--add-label` | `-l` | Label to add (repeatable) | +| `--remove-label` | `-r` | Label to remove (repeatable) | + +### 6. Open the TUI Run `tasklin` with no arguments to open the kanban board: diff --git a/cmd/update.go b/cmd/update.go new file mode 100644 index 0000000..83f04ed --- /dev/null +++ b/cmd/update.go @@ -0,0 +1,152 @@ +package cmd + +import ( + "fmt" + "os" + "strconv" + "strings" + + "github.com/frankcruz/tasklin/internal/store" + "github.com/spf13/cobra" +) + +var updateTitle string +var updateAddLabels []string +var updateRemoveLabels []string + +var updateCmd = &cobra.Command{ + Use: "update <ticket-id>", + Short: "Update a ticket's title or labels", + Args: cobra.ExactArgs(1), + RunE: runUpdate, +} + +func runUpdate(cmd *cobra.Command, args []string) error { + ticketID, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid ticket id: %s", args[0]) + } + + if !cmd.Flags().Changed("title") && !cmd.Flags().Changed("add-label") && !cmd.Flags().Changed("remove-label") { + return fmt.Errorf("nothing to update: specify --title, --add-label, or --remove-label") + } + + cwd, err := os.Getwd() + if err != nil { + return err + } + s := store.New(cwd) + if !s.Initialised() { + return fmt.Errorf(".todo/ not found — run 'tasklin init' first") + } + + tickets, err := s.ReadTickets() + if err != nil { + return err + } + + idx := -1 + for i, t := range tickets { + if t.ID == ticketID { + idx = i + break + } + } + if idx == -1 { + return fmt.Errorf("ticket %d not found", ticketID) + } + + t := &tickets[idx] + var changes []string + + if cmd.Flags().Changed("title") && updateTitle != t.Title { + changes = append(changes, fmt.Sprintf(" title: %q → %q", t.Title, updateTitle)) + t.Title = updateTitle + } + + if cmd.Flags().Changed("add-label") || cmd.Flags().Changed("remove-label") { + existing := make(map[string]bool, len(t.Labels)) + for _, l := range t.Labels { + existing[l] = true + } + + var added, removed []string + + for _, l := range updateAddLabels { + if !existing[l] { + t.Labels = append(t.Labels, l) + existing[l] = true + added = append(added, l) + } + } + + remove := make(map[string]bool, len(updateRemoveLabels)) + for _, l := range updateRemoveLabels { + remove[strings.ToLower(l)] = true + } + if len(remove) > 0 { + kept := t.Labels[:0] + for _, l := range t.Labels { + if remove[strings.ToLower(l)] { + removed = append(removed, l) + } else { + kept = append(kept, l) + } + } + t.Labels = kept + } + + if len(added) > 0 || len(removed) > 0 { + var parts []string + for _, l := range added { + parts = append(parts, "+"+l) + } + for _, l := range removed { + parts = append(parts, "-"+l) + } + changes = append(changes, " labels: "+strings.Join(parts, ", ")) + } + } + + if len(changes) == 0 { + fmt.Printf("#%d no changes\n", ticketID) + return nil + } + + if err := s.WriteTickets(tickets); err != nil { + return err + } + + if cmd.Flags().Changed("add-label") { + known, err := s.ReadLabels() + if err != nil { + return err + } + existing := make(map[string]bool, len(known)) + for _, l := range known { + existing[l] = true + } + for _, l := range updateAddLabels { + if !existing[l] { + known = append(known, l) + existing[l] = true + } + } + if err := s.WriteLabels(known); err != nil { + return err + } + } + + fmt.Printf("#%d %s\n", t.ID, t.Title) + for _, c := range changes { + fmt.Println(c) + } + return nil +} + +func init() { + updateCmd.Flags().StringVarP(&updateTitle, "title", "t", "", "new title") + updateCmd.Flags().StringArrayVarP(&updateAddLabels, "add-label", "l", nil, "label to add (repeatable)") + updateCmd.Flags().StringArrayVarP(&updateRemoveLabels, "remove-label", "r", nil, "label to remove (repeatable)") + rootCmd.AddCommand(updateCmd) +} diff --git a/docs/architecture.md b/docs/architecture.md index d4d103c..86e3f8d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -72,6 +72,7 @@ The binary is structured as a thin `cobra` CLI wrapper around a self-contained B | `add.go` | `tasklin add <title>` — creates a ticket from the CLI; supports `--label` and `--status` flags | | `move.go` | `tasklin move <id> <status>` — moves a ticket to a new status; no-op if already there | | `delete.go` | `tasklin delete <id>` — removes a ticket from tickets.yaml and archives it to deleted.yaml | +| `update.go` | `tasklin update <id>` — updates title (`--title`) and/or labels (`--add-label`, `--remove-label`); prints a change summary | | `transition.go` | `tasklin _transition <id> <status>` — internal command used by git hooks only | ### `internal/model/` From 528efd7d9903e4c67ca5094b97c2f361f47c0f3d Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 15 May 2026 00:10:30 +0000 Subject: [PATCH 5/5] feat: add `tasklin show` CLI command Displays ticket ID, title, status (with colour-coded dot matching the configured status colour), labels as [chip] tokens, and creation date. --verbose / -v appends the full transition history with timestamps. Uses lipgloss for consistent terminal styling. https://claude.ai/code/session_01BYNzLwixHZvfrTxgxd6Bh3 --- README.md | 23 ++++++- cmd/show.go | 156 +++++++++++++++++++++++++++++++++++++++++++ docs/architecture.md | 1 + 3 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 cmd/show.go diff --git a/README.md b/README.md index fc213de..fd29179 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,28 @@ Prints the ticket header followed by a line per change: | `--add-label` | `-l` | Label to add (repeatable) | | `--remove-label` | `-r` | Label to remove (repeatable) | -### 6. Open the TUI +### 6. Show a ticket + +```sh +tasklin show 42 +tasklin show 42 --verbose # includes full transition history +``` + +Example output: + +``` + #42 Fix login bug + ────────────────────────────────────────────── + Status ● In Progress + Labels [backend] [api] + Created 15 May 2026 + ────────────────────────────────────────────── + Transitions + + 14 May 2026 09:00 To Do → In Progress +``` + +### 7. Open the TUI Run `tasklin` with no arguments to open the kanban board: diff --git a/cmd/show.go b/cmd/show.go new file mode 100644 index 0000000..a4981de --- /dev/null +++ b/cmd/show.go @@ -0,0 +1,156 @@ +package cmd + +import ( + "fmt" + "os" + "strconv" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/frankcruz/tasklin/internal/store" + "github.com/spf13/cobra" +) + +var showVerbose bool + +var showCmd = &cobra.Command{ + Use: "show <ticket-id>", + Short: "Show ticket details", + Args: cobra.ExactArgs(1), + RunE: runShow, +} + +var ( + showIDStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")) + showTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")) + showKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Width(10) + showChipStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")) + showDimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + showBoldStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("252")) + showSepStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("238")) + showArrowStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("238")) +) + +func statusColor(name string) lipgloss.Color { + switch strings.ToLower(name) { + case "red": + return lipgloss.Color("1") + case "green": + return lipgloss.Color("2") + case "yellow": + return lipgloss.Color("3") + case "blue": + return lipgloss.Color("4") + case "magenta": + return lipgloss.Color("5") + case "cyan": + return lipgloss.Color("6") + case "white": + return lipgloss.Color("7") + default: + return lipgloss.Color(name) + } +} + +func runShow(cmd *cobra.Command, args []string) error { + ticketID, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid ticket id: %s", args[0]) + } + + cwd, err := os.Getwd() + if err != nil { + return err + } + s := store.New(cwd) + if !s.Initialised() { + return fmt.Errorf(".todo/ not found — run 'tasklin init' first") + } + + cfg, err := s.ReadConfig() + if err != nil { + return err + } + + tickets, err := s.ReadTickets() + if err != nil { + return err + } + + idx := -1 + for i, t := range tickets { + if t.ID == ticketID { + idx = i + break + } + } + if idx == -1 { + return fmt.Errorf("ticket %d not found", ticketID) + } + t := tickets[idx] + + // Resolve status color from config. + dotColor := lipgloss.Color("252") + for _, st := range cfg.Statuses { + if st.Name == t.Status { + dotColor = statusColor(st.Color) + break + } + } + + const sep = "──────────────────────────────────────────────" + + fmt.Println() + fmt.Printf(" %s %s\n", + showIDStyle.Render(fmt.Sprintf("#%d", t.ID)), + showTitleStyle.Render(t.Title), + ) + fmt.Println(" " + showSepStyle.Render(sep)) + + dot := lipgloss.NewStyle().Foreground(dotColor).Render("●") + fmt.Printf(" %s%s %s\n", + showKeyStyle.Render("Status"), + dot, + showBoldStyle.Render(t.Status), + ) + + if len(t.Labels) > 0 { + chips := make([]string, len(t.Labels)) + for i, l := range t.Labels { + chips[i] = showChipStyle.Render("[" + l + "]") + } + fmt.Printf(" %s%s\n", showKeyStyle.Render("Labels"), strings.Join(chips, " ")) + } else { + fmt.Printf(" %s%s\n", showKeyStyle.Render("Labels"), showDimStyle.Render("none")) + } + + fmt.Printf(" %s%s\n", + showKeyStyle.Render("Created"), + showDimStyle.Render(t.CreatedAt.Format("02 Jan 2006")), + ) + + if showVerbose { + fmt.Println(" " + showSepStyle.Render(sep)) + fmt.Printf(" %s\n", showBoldStyle.Render("Transitions")) + if len(t.Transitions) == 0 { + fmt.Printf(" %s\n", showDimStyle.Render(" none")) + } else { + fmt.Println() + for _, tr := range t.Transitions { + when := showDimStyle.Render(tr.At.Format("02 Jan 2006 15:04")) + from := showDimStyle.Render(tr.From) + arrow := showArrowStyle.Render("→") + to := showBoldStyle.Render(tr.To) + fmt.Printf(" %s %s %s %s\n", when, from, arrow, to) + } + } + } + + fmt.Println() + return nil +} + +func init() { + showCmd.Flags().BoolVarP(&showVerbose, "verbose", "v", false, "show full transition history") + rootCmd.AddCommand(showCmd) +} diff --git a/docs/architecture.md b/docs/architecture.md index 86e3f8d..0ddad6a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -73,6 +73,7 @@ The binary is structured as a thin `cobra` CLI wrapper around a self-contained B | `move.go` | `tasklin move <id> <status>` — moves a ticket to a new status; no-op if already there | | `delete.go` | `tasklin delete <id>` — removes a ticket from tickets.yaml and archives it to deleted.yaml | | `update.go` | `tasklin update <id>` — updates title (`--title`) and/or labels (`--add-label`, `--remove-label`); prints a change summary | +| `show.go` | `tasklin show <id>` — displays ticket status, title, and labels; `--verbose` adds full transition history | | `transition.go` | `tasklin _transition <id> <status>` — internal command used by git hooks only | ### `internal/model/`