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
11 changes: 11 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,17 @@ hey forward --to
hey habit
hey habit complete
hey habit complete --date
hey habit create
hey habit create --color
hey habit create --days
hey habit create --icon
hey habit create --name
hey habit delete
hey habit edit
hey habit edit --color
hey habit edit --days
hey habit edit --icon
hey habit edit --name
hey habit uncomplete
hey habit uncomplete --date
hey ignore
Expand Down
3 changes: 3 additions & 0 deletions API-COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ The remaining HTML-reading gaps use the SDK's authenticated HTML helper and are
| `/postings/spam.json` | POST | SDK `Postings().MarkSpam` | `hey spam <id>`, TUI `s` | covered |
| `/postings/mutings.json` | POST | SDK `Postings().Mute` | `hey ignore <id>`, TUI `-` | covered |
| `/postings/mutings.json` | DELETE | SDK `Postings().Unmute` | `hey stop-ignoring <id>`, TUI `+` | covered |
| `/calendar/habits.json` | POST | SDK `Habits().Create` | `hey habit create`, Calendar TUI `a` | covered |
| `/calendar/habits/{id}.json` | PATCH | SDK `Habits().Update` | `hey habit edit <id>`, Calendar TUI `e` | covered |
| `/calendar/habits/{id}.json` | DELETE | SDK `Habits().Delete` | `hey habit delete <id>`, Calendar TUI `x` | covered |
| `/calendar/days/{date}/habits/{id}/completions.json` | POST | SDK `Habits().Complete` | `hey habit complete <id>` | covered |
| `/calendar/days/{date}/habits/{id}/completions.json` | DELETE | SDK `Habits().Uncomplete` | `hey habit uncomplete <id>` | covered |
| `/calendar/days/{date}/journal_entry.json` | GET | SDK `Journal().Get` | `hey journal read [date]` | partial: falls back to legacy |
Expand Down
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ Press Shift+O to open Contacts. Use Enter to view a contact, `a` to add, `e` to

Press Shift+C to open Calendar, then `c` to manage time track categories. Create a category with `n`, rename the selected category with Enter or `r`, and press `x` twice to delete it. Time tracks in a deleted category become uncategorized.

In Calendar, press `a` to create a habit. Habits visible in the current calendar range can be selected with `[` and `]`, edited with `e`, and deleted by pressing `x` twice. Habit forms use Tab to move between fields and Ctrl+S to save.

## CLI Commands

Structured data commands support `--json` for full output and `--jq '<expression>'` to
Expand Down Expand Up @@ -325,10 +327,17 @@ hey todo delete 1 # delete
### Habits

```bash
hey habit complete 1 # mark habit done (today or --date YYYY-MM-DD)
hey habit uncomplete 1 # undo habit completion
hey habit create "Morning strength training" # create every day with weights and blue defaults
hey habit create "Practice piano" --icon music --color green --days mon,wed,fri
hey habit edit 1 --name "Evening strength training" # edit only the supplied fields
hey habit edit 1 --days 0,6 # Sunday and Saturday (names also work)
hey habit delete 1 # permanently delete the habit and its history
hey habit complete 1 # mark habit done (today or --date YYYY-MM-DD)
hey habit uncomplete 1 # undo habit completion
```

Habit IDs come from calendar recordings. Weekdays use `0` for Sunday through `6` for Saturday; full names and common abbreviations are accepted too.

### Time tracking

```bash
Expand Down
246 changes: 240 additions & 6 deletions internal/cmd/habit.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@ package cmd
import (
"fmt"
"strconv"
"strings"
"time"

"github.com/spf13/cobra"

hey "github.com/basecamp/hey-sdk/go/pkg/hey"

habitvalues "github.com/basecamp/hey-cli/internal/habit"
"github.com/basecamp/hey-cli/internal/output"
)

Expand All @@ -18,18 +22,248 @@ func newHabitCommand() *habitCommand {
habitCommand := &habitCommand{}
habitCommand.cmd = &cobra.Command{
Use: "habit",
Short: "Track completed habits",
Short: "Create and manage habits",
Annotations: map[string]string{
"agent_notes": "Subcommands: complete, uncomplete. Requires habit ID from calendar recordings.",
"agent_notes": "Subcommands: create, edit, delete, complete, uncomplete. Habit IDs are available in calendar recordings. Days accept weekday names or 0 (Sunday) through 6 (Saturday).",
},
}

habitCommand.cmd.AddCommand(newHabitCreateCommand().cmd)
habitCommand.cmd.AddCommand(newHabitEditCommand().cmd)
habitCommand.cmd.AddCommand(newHabitDeleteCommand().cmd)
habitCommand.cmd.AddCommand(newHabitCompleteCommand().cmd)
habitCommand.cmd.AddCommand(newHabitUncompleteCommand().cmd)

return habitCommand
}

// create

type habitCreateCommand struct {
cmd *cobra.Command
name string
icon string
color string
days string
}

func newHabitCreateCommand() *habitCreateCommand {
habitCreateCommand := &habitCreateCommand{}
habitCreateCommand.cmd = &cobra.Command{
Use: "create [name]",
Short: "Create a habit",
Example: ` hey habit create "Morning strength training"
hey habit create --name "Practice piano" --icon music --color green --days monday,wednesday,friday
echo "Read for thirty minutes" | hey habit create`,
RunE: habitCreateCommand.run,
Args: cobra.MaximumNArgs(1),
}

habitCreateCommand.cmd.Flags().StringVar(&habitCreateCommand.name, "name", "", "Habit name")
habitCreateCommand.cmd.Flags().StringVar(&habitCreateCommand.icon, "icon", habitvalues.DefaultIcon, "Habit icon. Accepted values: "+habitvalues.IconValues)
habitCreateCommand.cmd.Flags().StringVar(&habitCreateCommand.color, "color", habitvalues.DefaultColor, "Habit color. Accepted values: "+habitvalues.ColorValues)
habitCreateCommand.cmd.Flags().StringVar(&habitCreateCommand.days, "days", habitvalues.FormatDays(habitvalues.EveryDay), "Weekdays (names or 0-6, comma-separated)")

return habitCreateCommand
}

func (c *habitCreateCommand) run(cmd *cobra.Command, args []string) error {
if err := requireAuth(); err != nil {
return err
}

name := strings.TrimSpace(c.name)
nameFlagSet := cmd.Flags().Changed("name")
if nameFlagSet && len(args) > 0 {
return output.ErrUsage("--name and positional argument are mutually exclusive")
}
if len(args) > 0 {
name = strings.TrimSpace(args[0])
}
if name == "" && !nameFlagSet && len(args) == 0 && !stdinIsTerminal() {
var err error
name, err = readStdin()
if err != nil {
return err
}
name = strings.TrimSpace(name)
}
if name == "" {
return output.ErrUsageHint("name is required", "hey habit create \"Morning strength training\"")
}
icon := strings.TrimSpace(c.icon)
color := strings.TrimSpace(c.color)
if icon == "" || color == "" {
return output.ErrUsage("icon and color cannot be empty")
}
if err := habitvalues.ValidateIcon(icon); err != nil {
return output.ErrUsage(err.Error())
}
if err := habitvalues.ValidateColor(color); err != nil {
return output.ErrUsage(err.Error())
}
days, err := habitvalues.ParseDays(c.days)
if err != nil {
return output.ErrUsage(err.Error())
}

recording, err := sdk.Habits().Create(cmd.Context(), hey.HabitParams{Name: name, Icon: icon, Color: color, Days: days})
if err != nil {
return convertSDKError(err)
}
if writer.IsStyled() {
fmt.Fprintf(cmd.OutOrStdout(), "Habit %q created.\n", name)
return nil
}
return writeHabitMutation(recording, "Habit created")
}

// edit

type habitEditCommand struct {
cmd *cobra.Command
name string
icon string
color string
days string
}

func newHabitEditCommand() *habitEditCommand {
habitEditCommand := &habitEditCommand{}
habitEditCommand.cmd = &cobra.Command{
Use: "edit <id> [name]",
Aliases: []string{"update"},
Short: "Edit a habit",
Example: ` hey habit edit 789 --name "Evening walk"
hey habit edit 789 --days monday,tuesday,wednesday,thursday,friday
hey habit update 789 --icon walk --color gold`,
RunE: habitEditCommand.run,
Args: cobra.RangeArgs(1, 2),
}

habitEditCommand.cmd.Flags().StringVar(&habitEditCommand.name, "name", "", "New habit name")
habitEditCommand.cmd.Flags().StringVar(&habitEditCommand.icon, "icon", "", "New habit icon. Accepted values: "+habitvalues.IconValues)
habitEditCommand.cmd.Flags().StringVar(&habitEditCommand.color, "color", "", "New habit color. Accepted values: "+habitvalues.ColorValues)
habitEditCommand.cmd.Flags().StringVar(&habitEditCommand.days, "days", "", "New weekdays (names or 0-6, comma-separated)")

return habitEditCommand
}

func (c *habitEditCommand) run(cmd *cobra.Command, args []string) error {
if err := requireAuth(); err != nil {
return err
}

id, err := parseHabitID(args[0])
if err != nil {
return err
}
nameChanged := cmd.Flags().Changed("name") || len(args) == 2
if cmd.Flags().Changed("name") && len(args) == 2 {
return output.ErrUsage("--name and positional name are mutually exclusive")
}
name := strings.TrimSpace(c.name)
if len(args) == 2 {
name = strings.TrimSpace(args[1])
}
iconChanged := cmd.Flags().Changed("icon")
colorChanged := cmd.Flags().Changed("color")
daysChanged := cmd.Flags().Changed("days")
if !nameChanged && !iconChanged && !colorChanged && !daysChanged {
return output.ErrUsage("provide at least one of name, --icon, --color, or --days")
}
if nameChanged && name == "" {
return output.ErrUsage("name cannot be empty")
}
icon := strings.TrimSpace(c.icon)
color := strings.TrimSpace(c.color)
if iconChanged && icon == "" {
return output.ErrUsage("icon cannot be empty")
}
if iconChanged {
if validationErr := habitvalues.ValidateIcon(icon); validationErr != nil {
return output.ErrUsage(validationErr.Error())
}
}
if colorChanged && color == "" {
return output.ErrUsage("color cannot be empty")
}
if colorChanged {
if validationErr := habitvalues.ValidateColor(color); validationErr != nil {
return output.ErrUsage(validationErr.Error())
}
}
var days []int32
if daysChanged {
days, err = habitvalues.ParseDays(c.days)
if err != nil {
return output.ErrUsage(err.Error())
}
}

recording, err := sdk.Habits().Update(cmd.Context(), id, hey.HabitParams{Name: name, Icon: icon, Color: color, Days: days})
if err != nil {
return convertSDKError(err)
}
if writer.IsStyled() {
fmt.Fprintf(cmd.OutOrStdout(), "Habit %d updated.\n", id)
return nil
}
return writeHabitMutation(recording, "Habit updated")
}

// delete

type habitDeleteCommand struct {
cmd *cobra.Command
}

func newHabitDeleteCommand() *habitDeleteCommand {
habitDeleteCommand := &habitDeleteCommand{}
habitDeleteCommand.cmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a habit and its history",
Example: ` hey habit delete 789`,
RunE: habitDeleteCommand.run,
Args: usageExactOneArg(),
}
return habitDeleteCommand
}

func (c *habitDeleteCommand) run(cmd *cobra.Command, args []string) error {
if err := requireAuth(); err != nil {
return err
}
id, err := parseHabitID(args[0])
if err != nil {
return err
}
if err := sdk.Habits().Delete(cmd.Context(), id); err != nil {
return convertSDKError(err)
}
if writer.IsStyled() {
fmt.Fprintf(cmd.OutOrStdout(), "Habit %d deleted.\n", id)
return nil
}
return writeOK(nil, output.WithSummary("Habit deleted"))
}

func parseHabitID(value string) (int64, error) {
id, err := strconv.ParseInt(value, 10, 64)
if err != nil || id <= 0 {
return 0, output.ErrUsage(fmt.Sprintf("invalid habit ID: %s", value))
}
return id, nil
}

func writeHabitMutation(recording any, summary string) error {
normalized, err := normalizeAny(recording)
if err != nil {
return writeOK(nil, output.WithSummary(summary))
}
return writeOK(normalized, output.WithSummary(summary))
}

// complete

type habitCompleteCommand struct {
Expand Down Expand Up @@ -58,9 +292,9 @@ func (c *habitCompleteCommand) run(cmd *cobra.Command, args []string) error {
return err
}

id, err := strconv.ParseInt(args[0], 10, 64)
id, err := parseHabitID(args[0])
if err != nil {
return output.ErrUsage(fmt.Sprintf("invalid habit ID: %s", args[0]))
return err
}

date := c.date
Expand Down Expand Up @@ -114,9 +348,9 @@ func (c *habitUncompleteCommand) run(cmd *cobra.Command, args []string) error {
return err
}

id, err := strconv.ParseInt(args[0], 10, 64)
id, err := parseHabitID(args[0])
if err != nil {
return output.ErrUsage(fmt.Sprintf("invalid habit ID: %s", args[0]))
return err
}

date := c.date
Expand Down
Loading
Loading