CLI-CAL-001/002/003/004/005/006/007/008/009/010: Add calendar command group and CRUD plan commands

- Add hf calendar command group to command surface and router
- Implement schedule/show/edit/cancel/date-list commands
- Implement plan-schedule/plan-list/plan-edit/plan-cancel commands
- Add leaf help for all calendar commands
- Align CLI with backend calendar routes and response envelopes
- Support virtual slot ids for edit/cancel
- Validate with go build and go test ./...
This commit is contained in:
zhi
2026-04-01 07:02:36 +00:00
parent 97af3d3177
commit 0fe62ed430
4 changed files with 726 additions and 0 deletions

View File

@@ -0,0 +1,660 @@
package commands
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/client"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/config"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/output"
)
// --- Slot Commands ---
// RunCalendarSchedule implements `hf calendar schedule <slot-type> <scheduled-at> <estimated-duration> [--job <code>] [--date <yyyy-mm-dd>]`.
func RunCalendarSchedule(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
if len(args) < 3 {
output.Error("usage: hf calendar schedule <slot-type> <scheduled-at> <estimated-duration> [--job <code>] [--date <yyyy-mm-dd>] [--priority <0-99>]")
}
slotType := args[0]
scheduledAt := args[1]
estimatedDuration := args[2]
date, jobCode, priority := "", "", ""
remaining := args[3:]
for i := 0; i < len(remaining); i++ {
switch remaining[i] {
case "--date":
if i+1 >= len(remaining) {
output.Error("--date requires a value")
}
i++
date = remaining[i]
case "--job":
if i+1 >= len(remaining) {
output.Error("--job requires a value")
}
i++
jobCode = remaining[i]
case "--priority":
if i+1 >= len(remaining) {
output.Error("--priority requires a value")
}
i++
priority = remaining[i]
default:
output.Errorf("unknown flag: %s", remaining[i])
}
}
payload := map[string]interface{}{
"slot_type": slotType,
"scheduled_at": scheduledAt,
"estimated_duration": estimatedDuration,
}
if date != "" {
payload["date"] = date
}
if priority != "" {
payload["priority"] = priority
}
if jobCode != "" {
payload["event_type"] = "job"
payload["event_data"] = map[string]interface{}{"type": "Task", "code": jobCode}
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Post("/calendar/slots", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to schedule slot: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
// Check for warnings
var resp map[string]interface{}
if err := json.Unmarshal(data, &resp); err == nil {
if ws, ok := resp["warnings"]; ok {
if warnings, ok := ws.([]interface{}); ok && len(warnings) > 0 {
fmt.Println("⚠️ Workload warnings:")
for _, w := range warnings {
if wm, ok := w.(map[string]interface{}); ok {
if msg, ok := wm["message"].(string); ok {
fmt.Printf(" - %s\n", msg)
}
}
}
}
}
}
fmt.Printf("slot scheduled: %s at %s (%s min)\n", slotType, scheduledAt, estimatedDuration)
}
// RunCalendarShow implements `hf calendar show [--date <yyyy-mm-dd>]`.
func RunCalendarShow(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
date := ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--date":
if i+1 >= len(args) {
output.Error("--date requires a value")
}
i++
date = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
path := "/calendar/day"
if date != "" {
path += "?date=" + date
}
data, err := c.Get(path)
if err != nil {
output.Errorf("failed to show calendar: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var resp struct {
Slots []map[string]interface{} `json:"slots"`
}
if err := json.Unmarshal(data, &resp); err != nil {
output.Errorf("cannot parse calendar: %v", err)
}
slots := resp.Slots
if len(slots) == 0 {
if date != "" {
fmt.Printf("No slots for %s\n", date)
} else {
fmt.Println("No slots for today")
}
return
}
headers := []string{"ID", "TIME", "TYPE", "DURATION", "PRIORITY", "STATUS", "EVENT"}
var rows [][]string
for _, s := range slots {
slotID := fmt.Sprintf("%v", s["slot_id"])
scheduled := fmt.Sprintf("%v", s["scheduled_at"])
slotType := fmt.Sprintf("%v", s["slot_type"])
dur := fmt.Sprintf("%v min", s["estimated_duration"])
pri := fmt.Sprintf("%v", s["priority"])
status := fmt.Sprintf("%v", s["status"])
event := ""
if et, ok := s["event_type"]; ok && et != nil {
event = fmt.Sprintf("%v", et)
}
if ed, ok := s["event_data"]; ok && ed != nil {
if edm, ok := ed.(map[string]interface{}); ok {
if code, ok := edm["code"]; ok {
event += " " + fmt.Sprintf("%v", code)
}
if ev, ok := edm["event"]; ok {
event += " " + fmt.Sprintf("%v", ev)
}
}
}
if isVirt, ok := s["is_virtual"]; ok && isVirt == true {
slotID += " (plan)"
}
rows = append(rows, []string{slotID, scheduled, slotType, dur, pri, status, strings.TrimSpace(event)})
}
output.PrintTable(headers, rows)
}
// RunCalendarEdit implements `hf calendar edit [--date <yyyy-mm-dd>] <slot-id> [flags]`.
func RunCalendarEdit(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
if len(args) < 1 {
output.Error("usage: hf calendar edit <slot-id> [--date <yyyy-mm-dd>] [--slot-type <type>] [--estimated-duration <mins>] [--scheduled-at <HH:mm>] [--job <code>]")
}
slotID := args[0]
date, slotType, duration, scheduledAt, jobCode := "", "", "", "", ""
for i := 1; i < len(args); i++ {
switch args[i] {
case "--date":
if i+1 >= len(args) {
output.Error("--date requires a value")
}
i++
date = args[i]
case "--slot-type":
if i+1 >= len(args) {
output.Error("--slot-type requires a value")
}
i++
slotType = args[i]
case "--estimated-duration":
if i+1 >= len(args) {
output.Error("--estimated-duration requires a value")
}
i++
duration = args[i]
case "--scheduled-at":
if i+1 >= len(args) {
output.Error("--scheduled-at requires a value")
}
i++
scheduledAt = args[i]
case "--job":
if i+1 >= len(args) {
output.Error("--job requires a value")
}
i++
jobCode = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
payload := make(map[string]interface{})
if slotType != "" {
payload["slot_type"] = slotType
}
if duration != "" {
payload["estimated_duration"] = duration
}
if scheduledAt != "" {
payload["scheduled_at"] = scheduledAt
}
if jobCode != "" {
payload["event_type"] = "job"
payload["event_data"] = map[string]interface{}{"type": "Task", "code": jobCode}
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
path := "/calendar/slots/" + slotID
if strings.HasPrefix(slotID, "plan-") {
path = "/calendar/slots/virtual/" + slotID
}
_ = date // kept for CLI compatibility; backend identifies virtual slots via slot-id
data, err := c.Patch(path, bytes.NewReader(body))
if err != nil {
output.Errorf("failed to edit slot: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var resp map[string]interface{}
if err := json.Unmarshal(data, &resp); err == nil {
if ws, ok := resp["warnings"]; ok {
if warnings, ok := ws.([]interface{}); ok && len(warnings) > 0 {
fmt.Println("⚠️ Workload warnings:")
for _, w := range warnings {
if wm, ok := w.(map[string]interface{}); ok {
if msg, ok := wm["message"].(string); ok {
fmt.Printf(" - %s\n", msg)
}
}
}
}
}
}
fmt.Printf("slot edited: %s\n", slotID)
}
// RunCalendarCancel implements `hf calendar cancel [--date <yyyy-mm-dd>] <slot-id>`.
func RunCalendarCancel(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
if len(args) < 1 {
output.Error("usage: hf calendar cancel <slot-id> [--date <yyyy-mm-dd>]")
}
slotID := args[0]
date := ""
for i := 1; i < len(args); i++ {
switch args[i] {
case "--date":
if i+1 >= len(args) {
output.Error("--date requires a value")
}
i++
date = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
path := "/calendar/slots/" + slotID + "/cancel"
if strings.HasPrefix(slotID, "plan-") {
path = "/calendar/slots/virtual/" + slotID + "/cancel"
}
_ = date // kept for CLI compatibility; backend identifies virtual slots via slot-id
_, err = c.Post(path, nil)
if err != nil {
output.Errorf("failed to cancel slot: %v", err)
}
fmt.Printf("slot cancelled: %s\n", slotID)
}
// RunCalendarDateList implements `hf calendar date-list`.
func RunCalendarDateList(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Get("/calendar/dates")
if err != nil {
output.Errorf("failed to list dates: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var resp struct {
Dates []string `json:"dates"`
}
if err := json.Unmarshal(data, &resp); err != nil {
output.Errorf("cannot parse dates: %v", err)
}
dates := resp.Dates
if len(dates) == 0 {
fmt.Println("No future dates with materialized slots")
return
}
for _, d := range dates {
fmt.Println(d)
}
}
// --- Plan Commands ---
// RunCalendarPlanSchedule implements `hf calendar plan-schedule <slot-type> <estimated-duration> --at <HH:mm> [--on-day <day>] [--on-week <1-4>] [--on-month <month>]`.
func RunCalendarPlanSchedule(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
if len(args) < 2 {
output.Error("usage: hf calendar plan-schedule <slot-type> <estimated-duration> --at <HH:mm> [--on-day <day>] [--on-week <1-4>] [--on-month <month>]")
}
slotType := args[0]
duration := args[1]
atTime, onDay, onWeek, onMonth := "", "", "", ""
for i := 2; i < len(args); i++ {
switch args[i] {
case "--at":
if i+1 >= len(args) {
output.Error("--at requires a value")
}
i++
atTime = args[i]
case "--on-day":
if i+1 >= len(args) {
output.Error("--on-day requires a value")
}
i++
onDay = args[i]
case "--on-week":
if i+1 >= len(args) {
output.Error("--on-week requires a value")
}
i++
onWeek = args[i]
case "--on-month":
if i+1 >= len(args) {
output.Error("--on-month requires a value")
}
i++
onMonth = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if atTime == "" {
output.Error("--at is required")
}
payload := map[string]interface{}{
"slot_type": slotType,
"estimated_duration": duration,
"at_time": atTime,
}
if onDay != "" {
payload["on_day"] = onDay
}
if onWeek != "" {
payload["on_week"] = onWeek
}
if onMonth != "" {
payload["on_month"] = onMonth
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Post("/calendar/plans", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to create plan: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
fmt.Printf("plan created: %s at %s (%s min)\n", slotType, atTime, duration)
}
// RunCalendarPlanList implements `hf calendar plan-list`.
func RunCalendarPlanList(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Get("/calendar/plans")
if err != nil {
output.Errorf("failed to list plans: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var resp struct {
Plans []map[string]interface{} `json:"plans"`
}
if err := json.Unmarshal(data, &resp); err != nil {
output.Errorf("cannot parse plans: %v", err)
}
plans := resp.Plans
if len(plans) == 0 {
fmt.Println("No schedule plans")
return
}
headers := []string{"ID", "TYPE", "AT", "ON DAY", "ON WEEK", "ON MONTH", "DURATION", "ACTIVE"}
var rows [][]string
for _, p := range plans {
id := fmt.Sprintf("%v", p["id"])
slotType := fmt.Sprintf("%v", p["slot_type"])
at := fmt.Sprintf("%v", p["at_time"])
onDay := ""
if d, ok := p["on_day"]; ok && d != nil {
onDay = fmt.Sprintf("%v", d)
}
onWeek := ""
if w, ok := p["on_week"]; ok && w != nil {
onWeek = fmt.Sprintf("%v", w)
}
onMonth := ""
if m, ok := p["on_month"]; ok && m != nil {
onMonth = fmt.Sprintf("%v", m)
}
dur := fmt.Sprintf("%v min", p["estimated_duration"])
active := "yes"
if a, ok := p["is_active"]; ok && a == false {
active = "no"
}
rows = append(rows, []string{id, slotType, at, onDay, onWeek, onMonth, dur, active})
}
output.PrintTable(headers, rows)
}
// RunCalendarPlanEdit implements `hf calendar plan-edit <plan-id> [flags]`.
func RunCalendarPlanEdit(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
if len(args) < 1 {
output.Error("usage: hf calendar plan-edit <plan-id> [--at <HH:mm>] [--on-day <day>] [--on-week <1-4>] [--on-month <month>] [--slot-type <type>] [--estimated-duration <mins>]")
}
planID := args[0]
payload := make(map[string]interface{})
for i := 1; i < len(args); i++ {
switch args[i] {
case "--at":
if i+1 >= len(args) {
output.Error("--at requires a value")
}
i++
payload["at_time"] = args[i]
case "--on-day":
if i+1 >= len(args) {
output.Error("--on-day requires a value")
}
i++
payload["on_day"] = args[i]
case "--on-week":
if i+1 >= len(args) {
output.Error("--on-week requires a value")
}
i++
payload["on_week"] = args[i]
case "--on-month":
if i+1 >= len(args) {
output.Error("--on-month requires a value")
}
i++
payload["on_month"] = args[i]
case "--slot-type":
if i+1 >= len(args) {
output.Error("--slot-type requires a value")
}
i++
payload["slot_type"] = args[i]
case "--estimated-duration":
if i+1 >= len(args) {
output.Error("--estimated-duration requires a value")
}
i++
payload["estimated_duration"] = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if len(payload) == 0 {
output.Error("nothing to edit — provide at least one flag")
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Patch("/calendar/plans/"+planID, bytes.NewReader(body))
if err != nil {
output.Errorf("failed to edit plan: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
fmt.Printf("plan edited: %s\n", planID)
}
// RunCalendarPlanCancel implements `hf calendar plan-cancel <plan-id>`.
func RunCalendarPlanCancel(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
if len(args) < 1 {
output.Error("usage: hf calendar plan-cancel <plan-id>")
}
planID := args[0]
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Post("/calendar/plans/"+planID+"/cancel", nil)
if err != nil {
output.Errorf("failed to cancel plan: %v", err)
}
fmt.Printf("plan cancelled: %s\n", planID)
}

View File

@@ -170,6 +170,15 @@ func leafHelpSpec(group, cmd string) (leafHelp, bool) {
"proposal/essential/create": {Summary: "Create an essential", Usage: []string{"hf proposal essential create --proposal <proposal-code> --title <title> --type <feature|improvement|refactor> [--desc <desc>]"}, Flags: authFlagHelp()},
"proposal/essential/update": {Summary: "Update an essential", Usage: []string{"hf proposal essential update <essential-code> --proposal <proposal-code> [--title <title>] [--type <type>] [--desc <desc>]"}, Flags: authFlagHelp()},
"proposal/essential/delete": {Summary: "Delete an essential", Usage: []string{"hf proposal essential delete <essential-code> --proposal <proposal-code>"}, Flags: authFlagHelp()},
"calendar/schedule": {Summary: "Create a one-off calendar slot", Usage: []string{"hf calendar schedule <slot-type> <scheduled-at> <estimated-duration> [--job <code>] [--date <yyyy-mm-dd>] [--priority <0-99>]"}, Flags: authFlagHelp()},
"calendar/show": {Summary: "Show slots for a day", Usage: []string{"hf calendar show [--date <yyyy-mm-dd>]", "hf calendar show --json [--date <yyyy-mm-dd>]"}, Flags: authFlagHelp()},
"calendar/edit": {Summary: "Edit a calendar slot", Usage: []string{"hf calendar edit <slot-id> [--date <yyyy-mm-dd>] [--slot-type <type>] [--estimated-duration <mins>] [--job <code>] [--scheduled-at <HH:mm>]"}, Flags: authFlagHelp(), Notes: []string{"For virtual plan slots, pass the virtual id like plan-12-2026-04-01."}},
"calendar/cancel": {Summary: "Cancel a calendar slot", Usage: []string{"hf calendar cancel <slot-id> [--date <yyyy-mm-dd>]"}, Flags: authFlagHelp()},
"calendar/date-list": {Summary: "List future dates with materialized slots", Usage: []string{"hf calendar date-list", "hf calendar date-list --json"}, Flags: authFlagHelp()},
"calendar/plan-schedule": {Summary: "Create a recurring schedule plan", Usage: []string{"hf calendar plan-schedule <slot-type> <estimated-duration> --at <HH:mm> [--on-day <day>] [--on-week <1-4>] [--on-month <month>]"}, Flags: authFlagHelp()},
"calendar/plan-list": {Summary: "List recurring schedule plans", Usage: []string{"hf calendar plan-list", "hf calendar plan-list --json"}, Flags: authFlagHelp()},
"calendar/plan-edit": {Summary: "Edit a recurring schedule plan", Usage: []string{"hf calendar plan-edit <plan-id> [--at <HH:mm>] [--on-day <day>] [--on-week <1-4>] [--on-month <month>] [--slot-type <type>] [--estimated-duration <mins>]"}, Flags: authFlagHelp()},
"calendar/plan-cancel": {Summary: "Cancel a recurring schedule plan", Usage: []string{"hf calendar plan-cancel <plan-id>"}, Flags: authFlagHelp()},
"comment/add": {Summary: "Add a comment to a task", Usage: []string{"hf comment add --task <task-code> --content <text>"}, Flags: authFlagHelp()},
"comment/list": {Summary: "List comments for a task", Usage: []string{"hf comment list --task <task-code>"}, Flags: authFlagHelp()},
"worklog/add": {Summary: "Add a work log entry", Usage: []string{"hf worklog add --task <task-code> --hours <n> [--desc <text>] [--date <yyyy-mm-dd>]"}, Flags: authFlagHelp()},

View File

@@ -139,6 +139,21 @@ func CommandSurface() []Group {
{Name: "essential", Description: "Manage proposal essentials", Permitted: has(perms, "task.create")},
},
},
{
Name: "calendar",
Description: "Manage calendar slots and plans",
SubCommands: []Command{
{Name: "schedule", Description: "Create a one-off slot", Permitted: has(perms, "task.create")},
{Name: "show", Description: "Show slots for a day", Permitted: has(perms, "task.read")},
{Name: "edit", Description: "Edit a slot", Permitted: has(perms, "task.write")},
{Name: "cancel", Description: "Cancel a slot", Permitted: has(perms, "task.write")},
{Name: "date-list", Description: "List dates with materialized slots", Permitted: has(perms, "task.read")},
{Name: "plan-schedule", Description: "Create a recurring plan", Permitted: has(perms, "task.create")},
{Name: "plan-list", Description: "List plans", Permitted: has(perms, "task.read")},
{Name: "plan-edit", Description: "Edit a plan", Permitted: has(perms, "task.write")},
{Name: "plan-cancel", Description: "Cancel a plan", Permitted: has(perms, "task.write")},
},
},
{
Name: "comment",
Description: "Manage task comments",