Skip to content

Commit 5ddd179

Browse files
committed
feat(cmd): init, add, and ls commands with filters
Signed-off-by: Sanjay Santhanam <[email protected]>
1 parent 25657c1 commit 5ddd179

10 files changed

Lines changed: 395 additions & 2 deletions

File tree

cmd/tsk/main.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
// Package main is the tsk command-line entry point.
22
package main
33

4-
import "fmt"
4+
import (
5+
"fmt"
6+
"os"
7+
8+
"github.com/Sanjays2402/tsk/internal/commands"
9+
)
510

611
var (
712
version = "dev"
@@ -10,5 +15,9 @@ var (
1015
)
1116

1217
func main() {
13-
fmt.Printf("tsk %s (%s) %s\n", version, commit, date)
18+
commands.SetVersion(version, commit, date)
19+
if err := commands.NewRoot().Execute(); err != nil {
20+
fmt.Fprintln(os.Stderr, "error:", err)
21+
os.Exit(1)
22+
}
1423
}

go.mod

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
11
module github.com/Sanjays2402/tsk
22

33
go 1.26.1
4+
5+
require github.com/spf13/cobra v1.10.2
6+
7+
require (
8+
github.com/inconshreveable/mousetrap v1.1.0 // indirect
9+
github.com/spf13/pflag v1.0.9 // indirect
10+
)

go.sum

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
2+
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
3+
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
4+
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
5+
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
6+
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
7+
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
8+
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
9+
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
10+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

internal/commands/add.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package commands
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"time"
7+
8+
"github.com/Sanjays2402/tsk/internal/model"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
func newAddCmd() *cobra.Command {
13+
var (
14+
priorityStr string
15+
dueStr string
16+
tags []string
17+
notes string
18+
)
19+
cmd := &cobra.Command{
20+
Use: "add <title>",
21+
Short: "Add a new task",
22+
Args: cobra.MinimumNArgs(1),
23+
RunE: func(cmd *cobra.Command, args []string) error {
24+
title := strings.TrimSpace(strings.Join(args, " "))
25+
if title == "" {
26+
return fmt.Errorf("title required")
27+
}
28+
prio, err := model.ParsePriority(priorityStr)
29+
if err != nil {
30+
return err
31+
}
32+
task := model.Task{
33+
Title: title,
34+
Priority: prio,
35+
Tags: tags,
36+
Notes: strings.TrimSpace(notes),
37+
Created: time.Now(),
38+
}
39+
if dueStr != "" {
40+
t, err := time.ParseInLocation(model.DateLayout, dueStr, time.Local)
41+
if err != nil {
42+
return fmt.Errorf("invalid --due (want YYYY-MM-DD): %w", err)
43+
}
44+
task.Due = &t
45+
}
46+
s, err := resolveStore(cmd, false)
47+
if err != nil {
48+
return err
49+
}
50+
id := s.Add(task)
51+
if err := s.Save(); err != nil {
52+
return err
53+
}
54+
fmt.Fprintf(cmd.OutOrStdout(), "added #%d: %s\n", id, title)
55+
return nil
56+
},
57+
}
58+
cmd.Flags().StringVarP(&priorityStr, "priority", "p", "medium", "priority (low|medium|high|urgent)")
59+
cmd.Flags().StringVarP(&dueStr, "due", "d", "", "due date (YYYY-MM-DD)")
60+
cmd.Flags().StringArrayVarP(&tags, "tag", "t", nil, "tag (repeatable)")
61+
cmd.Flags().StringVarP(&notes, "notes", "n", "", "freeform notes")
62+
return cmd
63+
}

internal/commands/helpers.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package commands
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/Sanjays2402/tsk/internal/store"
8+
"github.com/spf13/cobra"
9+
)
10+
11+
// resolveStore opens (and returns) the store at --file, nearest .tsk.md, or a cwd fallback.
12+
// If requireExisting is true, an error is returned when no file is found.
13+
func resolveStore(cmd *cobra.Command, requireExisting bool) (*store.Store, error) {
14+
path, _ := cmd.Flags().GetString("file")
15+
if path == "" {
16+
if requireExisting {
17+
resolved, ok := store.Resolve("")
18+
if !ok {
19+
return nil, fmt.Errorf("no .tsk.md found; run `tsk init`")
20+
}
21+
path = resolved
22+
} else {
23+
path = store.ResolveOrCreate("")
24+
}
25+
}
26+
if requireExisting {
27+
if _, err := os.Stat(path); err != nil {
28+
return nil, fmt.Errorf("no .tsk.md at %s; run `tsk init`", path)
29+
}
30+
}
31+
return store.Load(path)
32+
}

internal/commands/init.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package commands
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
8+
"github.com/Sanjays2402/tsk/internal/store"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
func newInitCmd() *cobra.Command {
13+
return &cobra.Command{
14+
Use: "init",
15+
Short: "Create a .tsk.md in the current directory",
16+
RunE: func(cmd *cobra.Command, _ []string) error {
17+
path, _ := cmd.Flags().GetString("file")
18+
if path == "" {
19+
cwd, err := os.Getwd()
20+
if err != nil {
21+
return err
22+
}
23+
path = filepath.Join(cwd, store.FileName)
24+
}
25+
if _, err := os.Stat(path); err == nil {
26+
return fmt.Errorf("%s already exists", path)
27+
}
28+
if err := store.AtomicWriteFile(path, []byte("# tsk\n\n"), 0o644); err != nil {
29+
return err
30+
}
31+
fmt.Fprintf(cmd.OutOrStdout(), "created %s\n", path)
32+
return nil
33+
},
34+
}
35+
}

internal/commands/ls.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package commands
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"strings"
8+
"time"
9+
10+
"github.com/Sanjays2402/tsk/internal/model"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
type lsFilters struct {
15+
done, all, today, overdue, upcoming bool
16+
tag string
17+
priorityStr string
18+
asJSON bool
19+
}
20+
21+
func newLsCmd() *cobra.Command {
22+
f := lsFilters{}
23+
cmd := &cobra.Command{
24+
Use: "ls",
25+
Aliases: []string{"list"},
26+
Short: "List tasks (undone by default)",
27+
RunE: func(cmd *cobra.Command, _ []string) error {
28+
s, err := resolveStore(cmd, true)
29+
if err != nil {
30+
return err
31+
}
32+
tasks, err := applyFilters(s.Tasks, f)
33+
if err != nil {
34+
return err
35+
}
36+
return printTasks(cmd.OutOrStdout(), tasks, f.asJSON)
37+
},
38+
}
39+
cmd.Flags().BoolVar(&f.done, "done", false, "only show done tasks")
40+
cmd.Flags().BoolVar(&f.all, "all", false, "show all tasks (done + undone)")
41+
cmd.Flags().BoolVar(&f.today, "today", false, "only show tasks due today")
42+
cmd.Flags().BoolVar(&f.overdue, "overdue", false, "only show overdue tasks")
43+
cmd.Flags().BoolVar(&f.upcoming, "upcoming", false, "only show tasks due in the future")
44+
cmd.Flags().StringVar(&f.tag, "tag", "", "only show tasks with this tag")
45+
cmd.Flags().StringVar(&f.priorityStr, "priority", "", "only show tasks with this priority")
46+
cmd.Flags().BoolVar(&f.asJSON, "json", false, "emit JSON")
47+
return cmd
48+
}
49+
50+
func applyFilters(in []model.Task, f lsFilters) ([]model.Task, error) {
51+
var prio model.Priority
52+
prioFilter := false
53+
if f.priorityStr != "" {
54+
p, err := model.ParsePriority(f.priorityStr)
55+
if err != nil {
56+
return nil, err
57+
}
58+
prio = p
59+
prioFilter = true
60+
}
61+
now := time.Now()
62+
out := make([]model.Task, 0, len(in))
63+
for _, t := range in {
64+
switch {
65+
case f.all:
66+
// everything
67+
case f.done:
68+
if !t.Done {
69+
continue
70+
}
71+
default:
72+
if t.Done {
73+
continue
74+
}
75+
}
76+
if f.today && !t.IsDueToday(now) {
77+
continue
78+
}
79+
if f.overdue && !t.IsOverdue(now) {
80+
continue
81+
}
82+
if f.upcoming && !t.IsUpcoming(now) {
83+
continue
84+
}
85+
if f.tag != "" && !t.HasTag(f.tag) {
86+
continue
87+
}
88+
if prioFilter && t.Priority != prio {
89+
continue
90+
}
91+
out = append(out, t)
92+
}
93+
return out, nil
94+
}
95+
96+
func printTasks(w io.Writer, tasks []model.Task, asJSON bool) error {
97+
if asJSON {
98+
enc := json.NewEncoder(w)
99+
enc.SetIndent("", " ")
100+
return enc.Encode(tasks)
101+
}
102+
if len(tasks) == 0 {
103+
fmt.Fprintln(w, "no tasks")
104+
return nil
105+
}
106+
for _, t := range tasks {
107+
check := " "
108+
if t.Done {
109+
check = "x"
110+
}
111+
line := fmt.Sprintf("[%s] #%d %s (%s)", check, t.ID, t.Title, t.Priority)
112+
if t.Due != nil {
113+
line += " due:" + t.Due.Format(model.DateLayout)
114+
}
115+
if len(t.Tags) > 0 {
116+
line += " #" + strings.Join(t.Tags, " #")
117+
}
118+
fmt.Fprintln(w, line)
119+
}
120+
return nil
121+
}

internal/commands/optional.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package commands
2+
3+
import "github.com/spf13/cobra"
4+
5+
// optionalFactories holds subcommand constructors wired by later commits.
6+
var optionalFactories []func() *cobra.Command
7+
8+
// RegisterCommand allows later-stage packages (or test code) to add subcommands
9+
// without modifying root construction.
10+
func RegisterCommand(f func() *cobra.Command) {
11+
optionalFactories = append(optionalFactories, f)
12+
}
13+
14+
func attachOptional(root *cobra.Command) {
15+
for _, f := range optionalFactories {
16+
root.AddCommand(f())
17+
}
18+
}

internal/commands/root.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Package commands wires up cobra commands for the tsk CLI.
2+
package commands
3+
4+
import (
5+
"fmt"
6+
7+
"github.com/spf13/cobra"
8+
)
9+
10+
// Version metadata plumbed from main via SetVersion.
11+
var (
12+
buildVersion = "dev"
13+
buildCommit = "none"
14+
buildDate = "unknown"
15+
)
16+
17+
// SetVersion injects build metadata from main.
18+
func SetVersion(version, commit, date string) {
19+
buildVersion = version
20+
buildCommit = commit
21+
buildDate = date
22+
}
23+
24+
// NewRoot returns the root cobra command. All subcommands attach here.
25+
func NewRoot() *cobra.Command {
26+
root := &cobra.Command{
27+
Use: "tsk",
28+
Short: "Fast, keyboard-first markdown todo manager",
29+
Long: "tsk is a souped-up TUI + CLI todo manager backed by a human-readable markdown file (.tsk.md).",
30+
SilenceUsage: true,
31+
SilenceErrors: true,
32+
}
33+
root.PersistentFlags().String("file", "", "path to .tsk.md (default: nearest .tsk.md or ~/.tsk/global.md)")
34+
35+
root.AddCommand(
36+
newInitCmd(),
37+
newAddCmd(),
38+
newLsCmd(),
39+
newVersionCmd(),
40+
newTUICmd(),
41+
)
42+
attachOptional(root)
43+
44+
// Run the TUI when invoked with no subcommand and no args.
45+
root.RunE = func(cmd *cobra.Command, args []string) error {
46+
if len(args) == 0 {
47+
return runTUI(cmd)
48+
}
49+
return fmt.Errorf("unknown command %q", args[0])
50+
}
51+
return root
52+
}
53+
54+
func newVersionCmd() *cobra.Command {
55+
return &cobra.Command{
56+
Use: "version",
57+
Short: "Print build version",
58+
RunE: func(cmd *cobra.Command, _ []string) error {
59+
fmt.Fprintf(cmd.OutOrStdout(), "tsk %s (commit %s, built %s)\n", buildVersion, buildCommit, buildDate)
60+
return nil
61+
},
62+
}
63+
}

0 commit comments

Comments
 (0)