● Zion Boggan
repos/Agent Time Ledger/internal/cli/cli.go
zionboggan.com ↗
335 lines · go
History for this file →
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
}