Skip to content
Merged
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
88 changes: 87 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,93 @@ 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. 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. 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. 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. 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:

Expand Down
112 changes: 112 additions & 0 deletions cmd/add.go
Original file line number Diff line number Diff line change
@@ -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 <title>",
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)
}
72 changes: 72 additions & 0 deletions cmd/delete.go
Original file line number Diff line number Diff line change
@@ -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)
}
87 changes: 87 additions & 0 deletions cmd/move.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading