| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "io" |
| 7 | "sort" |
| 8 | "strings" |
| 9 | |
| 10 | "github.com/zionboggan/agent-time-ledger/internal/clock" |
| 11 | "github.com/zionboggan/agent-time-ledger/internal/ledger" |
| 12 | "github.com/zionboggan/agent-time-ledger/internal/mcp" |
| 13 | "github.com/zionboggan/agent-time-ledger/internal/state" |
| 14 | ) |
| 15 | |
| 16 | func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { |
| 17 | store, err := state.DefaultStore() |
| 18 | if err != nil { |
| 19 | fmt.Fprintln(stderr, err) |
| 20 | return 1 |
| 21 | } |
| 22 | service := ledger.NewService(store) |
| 23 | if err := run(args, stdin, stdout, stderr, service); err != nil { |
| 24 | fmt.Fprintln(stderr, "atl:", err) |
| 25 | return 1 |
| 26 | } |
| 27 | return 0 |
| 28 | } |
| 29 | |
| 30 | func run(args []string, stdin io.Reader, stdout, stderr io.Writer, service *ledger.Service) error { |
| 31 | if len(args) == 0 { |
| 32 | printUsage(stdout) |
| 33 | return nil |
| 34 | } |
| 35 | |
| 36 | switch args[0] { |
| 37 | case "now": |
| 38 | jsonOut, rest, err := extractJSONFlag(args[1:]) |
| 39 | if err != nil { |
| 40 | return err |
| 41 | } |
| 42 | timezone, ok := optionalStringFlag(rest, "--tz") |
| 43 | if !ok { |
| 44 | timezone, _ = optionalStringFlag(rest, "--timezone") |
| 45 | } |
| 46 | rest = removeStringFlag(rest, "--tz", "--timezone") |
| 47 | if len(rest) != 0 { |
| 48 | return fmt.Errorf("unexpected argument %q", rest[0]) |
| 49 | } |
| 50 | response, err := service.NowResponseIn(timezone) |
| 51 | if err != nil { |
| 52 | return err |
| 53 | } |
| 54 | if jsonOut { |
| 55 | return writeJSON(stdout, response) |
| 56 | } |
| 57 | fmt.Fprintf(stdout, "%s (%s, UTC%s)\nUTC: %s\n", response.Timestamp, response.Timezone, response.UTCOffset, response.UTCTimestamp) |
| 58 | return nil |
| 59 | case "session": |
| 60 | return runSession(args[1:], stdout, service) |
| 61 | case "mark": |
| 62 | return runMark(args[1:], stdout, service) |
| 63 | case "stale": |
| 64 | return runStale(args[1:], stdout, service) |
| 65 | case "report": |
| 66 | return runReport(args[1:], stdout, service) |
| 67 | case "serve-mcp": |
| 68 | return mcp.Serve(stdin, stdout, stderr, service) |
| 69 | case "help", "-h", "--help": |
| 70 | printUsage(stdout) |
| 71 | return nil |
| 72 | default: |
| 73 | return fmt.Errorf("unknown command %q", args[0]) |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | func runSession(args []string, stdout io.Writer, service *ledger.Service) error { |
| 78 | if len(args) == 0 { |
| 79 | return fmt.Errorf("session command is required") |
| 80 | } |
| 81 | switch args[0] { |
| 82 | case "start": |
| 83 | name, err := stringFlag(args[1:], "--name") |
| 84 | if err != nil { |
| 85 | return err |
| 86 | } |
| 87 | status, err := service.StartSession(name) |
| 88 | if err != nil { |
| 89 | return err |
| 90 | } |
| 91 | fmt.Fprintf(stdout, "started session %s (%s)\n", status.ID, status.Name) |
| 92 | return nil |
| 93 | case "status": |
| 94 | jsonOut, err := parseJSONFlag(args[1:]) |
| 95 | if err != nil { |
| 96 | return err |
| 97 | } |
| 98 | status, err := service.SessionStatus() |
| 99 | if err != nil { |
| 100 | return err |
| 101 | } |
| 102 | if jsonOut { |
| 103 | return writeJSON(stdout, status) |
| 104 | } |
| 105 | if !status.Active { |
| 106 | fmt.Fprintln(stdout, "no active session") |
| 107 | return nil |
| 108 | } |
| 109 | fmt.Fprintf(stdout, "%s (%s): %s elapsed\n", status.ID, status.Name, status.ElapsedHuman) |
| 110 | return nil |
| 111 | case "end": |
| 112 | status, err := service.EndSession() |
| 113 | if err != nil { |
| 114 | return err |
| 115 | } |
| 116 | fmt.Fprintf(stdout, "ended session %s (%s): %s elapsed\n", status.ID, status.Name, status.ElapsedHuman) |
| 117 | return nil |
| 118 | default: |
| 119 | return fmt.Errorf("unknown session command %q", args[0]) |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | func runMark(args []string, stdout io.Writer, service *ledger.Service) error { |
| 124 | if len(args) == 0 { |
| 125 | return fmt.Errorf("mark command is required") |
| 126 | } |
| 127 | switch args[0] { |
| 128 | case "start": |
| 129 | if len(args) != 2 { |
| 130 | return fmt.Errorf("usage: atl mark start <name>") |
| 131 | } |
| 132 | mark, err := service.StartMark(args[1]) |
| 133 | if err != nil { |
| 134 | return err |
| 135 | } |
| 136 | fmt.Fprintf(stdout, "started mark %s at %s\n", mark.Name, mark.StartedAt) |
| 137 | return nil |
| 138 | case "check": |
| 139 | if len(args) < 2 { |
| 140 | return fmt.Errorf("usage: atl mark check <name> [--json]") |
| 141 | } |
| 142 | jsonOut, rest, err := extractJSONFlag(args[2:]) |
| 143 | if err != nil { |
| 144 | return err |
| 145 | } |
| 146 | if len(rest) != 0 { |
| 147 | return fmt.Errorf("unexpected argument %q", rest[0]) |
| 148 | } |
| 149 | mark, err := service.MarkElapsed(args[1]) |
| 150 | if err != nil { |
| 151 | return err |
| 152 | } |
| 153 | if jsonOut { |
| 154 | return writeJSON(stdout, mark) |
| 155 | } |
| 156 | fmt.Fprintf(stdout, "%s: %s elapsed\n", mark.Name, mark.ElapsedHuman) |
| 157 | return nil |
| 158 | case "list": |
| 159 | marks, err := service.ListMarks() |
| 160 | if err != nil { |
| 161 | return err |
| 162 | } |
| 163 | sort.Slice(marks, func(i, j int) bool { return marks[i].Name < marks[j].Name }) |
| 164 | if len(marks) == 0 { |
| 165 | fmt.Fprintln(stdout, "no marks") |
| 166 | return nil |
| 167 | } |
| 168 | for _, mark := range marks { |
| 169 | fmt.Fprintf(stdout, "%s\t%s\t%s\n", mark.Name, mark.StartedAt, mark.ElapsedHuman) |
| 170 | } |
| 171 | return nil |
| 172 | case "delete": |
| 173 | if len(args) != 2 { |
| 174 | return fmt.Errorf("usage: atl mark delete <name>") |
| 175 | } |
| 176 | if err := service.DeleteMark(args[1]); err != nil { |
| 177 | return err |
| 178 | } |
| 179 | fmt.Fprintf(stdout, "deleted mark %s\n", args[1]) |
| 180 | return nil |
| 181 | default: |
| 182 | return fmt.Errorf("unknown mark command %q", args[0]) |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | func runStale(args []string, stdout io.Writer, service *ledger.Service) error { |
| 187 | jsonOut, rest, err := extractJSONFlag(args) |
| 188 | if err != nil { |
| 189 | return err |
| 190 | } |
| 191 | timestampValue, _ := optionalStringFlag(rest, "--timestamp") |
| 192 | markName, _ := optionalStringFlag(rest, "--mark") |
| 193 | ttlValue, err := stringFlag(rest, "--ttl") |
| 194 | if err != nil { |
| 195 | return err |
| 196 | } |
| 197 | ttl, err := clock.ParseDuration(ttlValue) |
| 198 | if err != nil { |
| 199 | return err |
| 200 | } |
| 201 | |
| 202 | var response ledger.StaleResponse |
| 203 | switch { |
| 204 | case timestampValue != "" && markName != "": |
| 205 | return fmt.Errorf("use either --timestamp or --mark, not both") |
| 206 | case timestampValue != "": |
| 207 | reference, err := clock.ParseRFC3339(timestampValue) |
| 208 | if err != nil { |
| 209 | return fmt.Errorf("invalid --timestamp: %w", err) |
| 210 | } |
| 211 | response, err = service.StaleFromTimestamp(reference, ttl) |
| 212 | case markName != "": |
| 213 | response, err = service.StaleFromMark(markName, ttl) |
| 214 | default: |
| 215 | return fmt.Errorf("either --timestamp or --mark is required") |
| 216 | } |
| 217 | if err != nil { |
| 218 | return err |
| 219 | } |
| 220 | if jsonOut { |
| 221 | return writeJSON(stdout, response) |
| 222 | } |
| 223 | fmt.Fprintf(stdout, "stale=%t elapsed=%s ttl=%s\n", response.Stale, response.ElapsedHuman, response.TTL) |
| 224 | return nil |
| 225 | } |
| 226 | |
| 227 | func runReport(args []string, stdout io.Writer, service *ledger.Service) error { |
| 228 | jsonOut, rest, err := extractJSONFlag(args) |
| 229 | if err != nil { |
| 230 | return err |
| 231 | } |
| 232 | if len(rest) > 1 || (len(rest) == 1 && rest[0] != "today") { |
| 233 | return fmt.Errorf("usage: atl report [today] [--json]") |
| 234 | } |
| 235 | report, err := service.ReportToday() |
| 236 | if err != nil { |
| 237 | return err |
| 238 | } |
| 239 | if jsonOut { |
| 240 | return writeJSON(stdout, report) |
| 241 | } |
| 242 | fmt.Fprintf(stdout, "report %s: %d events, %d open marks, active_session=%t\n", report.Date, report.TotalEventsToday, report.OpenMarks, report.ActiveSession) |
| 243 | return nil |
| 244 | } |
| 245 | |
| 246 | func printUsage(w io.Writer) { |
| 247 | fmt.Fprintln(w, `Usage: |
| 248 | atl now [--json] [--tz <iana-timezone>] |
| 249 | atl session start --name <name> |
| 250 | atl session status [--json] |
| 251 | atl session end |
| 252 | atl mark start <name> |
| 253 | atl mark check <name> [--json] |
| 254 | atl mark list |
| 255 | atl mark delete <name> |
| 256 | atl stale (--timestamp <rfc3339> | --mark <name>) --ttl <duration> [--json] |
| 257 | atl report [today] [--json] |
| 258 | atl serve-mcp`) |
| 259 | } |
| 260 | |
| 261 | func parseJSONFlag(args []string) (bool, error) { |
| 262 | jsonOut, rest, err := extractJSONFlag(args) |
| 263 | if err != nil { |
| 264 | return false, err |
| 265 | } |
| 266 | if len(rest) != 0 { |
| 267 | return false, fmt.Errorf("unexpected argument %q", rest[0]) |
| 268 | } |
| 269 | return jsonOut, nil |
| 270 | } |
| 271 | |
| 272 | func extractJSONFlag(args []string) (bool, []string, error) { |
| 273 | jsonOut := false |
| 274 | rest := make([]string, 0, len(args)) |
| 275 | for _, arg := range args { |
| 276 | if arg == "--json" { |
| 277 | jsonOut = true |
| 278 | continue |
| 279 | } |
| 280 | rest = append(rest, arg) |
| 281 | } |
| 282 | return jsonOut, rest, nil |
| 283 | } |
| 284 | |
| 285 | func stringFlag(args []string, name string) (string, error) { |
| 286 | value, ok := optionalStringFlag(args, name) |
| 287 | if !ok || value == "" { |
| 288 | return "", fmt.Errorf("%s is required", name) |
| 289 | } |
| 290 | return value, nil |
| 291 | } |
| 292 | |
| 293 | func optionalStringFlag(args []string, name string) (string, bool) { |
| 294 | for i, arg := range args { |
| 295 | if arg == name && i+1 < len(args) { |
| 296 | return args[i+1], true |
| 297 | } |
| 298 | if strings.HasPrefix(arg, name+"=") { |
| 299 | return strings.TrimPrefix(arg, name+"="), true |
| 300 | } |
| 301 | } |
| 302 | return "", false |
| 303 | } |
| 304 | |
| 305 | func removeStringFlag(args []string, names ...string) []string { |
| 306 | nameSet := map[string]struct{}{} |
| 307 | for _, name := range names { |
| 308 | nameSet[name] = struct{}{} |
| 309 | } |
| 310 | rest := make([]string, 0, len(args)) |
| 311 | for i := 0; i < len(args); i++ { |
| 312 | arg := args[i] |
| 313 | if _, ok := nameSet[arg]; ok { |
| 314 | i++ |
| 315 | continue |
| 316 | } |
| 317 | removed := false |
| 318 | for name := range nameSet { |
| 319 | if strings.HasPrefix(arg, name+"=") { |
| 320 | removed = true |
| 321 | break |
| 322 | } |
| 323 | } |
| 324 | if !removed { |
| 325 | rest = append(rest, arg) |
| 326 | } |
| 327 | } |
| 328 | return rest |
| 329 | } |
| 330 | |
| 331 | func writeJSON(w io.Writer, value any) error { |
| 332 | encoder := json.NewEncoder(w) |
| 333 | encoder.SetIndent("", " ") |
| 334 | return encoder.Encode(value) |
| 335 | } |