Skip to content

Commit dadf55f

Browse files
committed
polish: tighten CLI UX and strengthen CI
1 parent 4b54912 commit dadf55f

4 files changed

Lines changed: 129 additions & 21 deletions

File tree

.github/workflows/ci.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,21 @@ jobs:
2727
shell: bash
2828
run: |
2929
test -z "$(gofmt -l .)"
30+
- name: go vet
31+
run: go vet ./...
3032
- name: go test
3133
run: go test ./...
3234
- name: go build
3335
run: go build ./cmd/msx
36+
37+
race:
38+
name: race (ubuntu-latest)
39+
runs-on: ubuntu-latest
40+
steps:
41+
- uses: actions/checkout@v4
42+
- uses: actions/setup-go@v5
43+
with:
44+
go-version: '1.26'
45+
cache: true
46+
- name: go test -race
47+
run: go test -race ./...

README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ msx mail --profile personal --top 10
133133
msx mail --profile personal --sender noreply@example.com --since 2026-03-01T00:00:00Z
134134
msx mail --profile personal --folder inbox --unread --top 25
135135
msx mail --profile personal --query invoice --top 20
136+
msx mail --profile personal --subject invoice --top 50
136137
```
137138

138139
### Calendar lookup
@@ -148,6 +149,7 @@ msx agenda --profile personal --query dentist
148149
msx files --profile personal --top 20
149150
msx files --profile personal --path Documents
150151
msx files --profile personal --query passport --top 20
152+
msx files --profile personal --path Documents --kind folders
151153
```
152154

153155
### Contacts
@@ -188,7 +190,8 @@ Current choices made for agent use:
188190
- profile selection is explicit and cheap
189191
- read-only operations for safe automation
190192
- globals can appear before or after subcommands
191-
- basic input validation for common footguns (`--top > 0`, valid RFC3339 timestamps, `--end` after `--start`)
193+
- basic input validation for common footguns (`--top > 0`, valid RFC3339 timestamps, `--end` after `--start`, constrained enums like `--kind`)
194+
- client-side narrowing where it improves ergonomics without hiding Graph data (`mail --subject`, `files --kind`)
192195

193196
## Safety
194197

@@ -211,15 +214,17 @@ Covered areas now include:
211214
- token JSON parsing and refresh-token preservation
212215
- store durability/serialization behavior
213216
- forced refresh persistence
214-
- Graph `401` retry behavior
215-
- CLI global flag parsing and event filtering helpers
217+
- Graph `401` retry behavior and search headers
218+
- CLI global flag parsing, help-path behavior, and filtering helpers for mail/events/files
216219

217220
## CI
218221

219222
GitHub Actions now runs on push and pull request:
220223
- `gofmt -l .` to catch formatting drift
224+
- `go vet ./...`
221225
- `go test ./...`
222226
- `go build ./cmd/msx`
227+
- `go test -race ./...` on Ubuntu for the concurrency-sensitive auth/store path
223228
- matrix coverage on Ubuntu, macOS, and Windows
224229

225230
## Verified non-destructive flows

cmd/msx/main.go

Lines changed: 74 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"encoding/json"
5+
"errors"
56
"flag"
67
"fmt"
78
"os"
@@ -19,23 +20,28 @@ type globalFlags struct {
1920
format string
2021
}
2122

23+
var errHelpShown = errors.New("help shown")
24+
2225
const usageText = `usage: msx [--profile name] [--format text|json] <command> [flags]
2326
2427
commands:
25-
login Start device-code login and save tokens locally
26-
import-op Import an existing Microsoft account from 1Password
27-
profiles List configured profiles
28-
whoami Show the current Graph account
29-
mail List mail with optional filters
30-
agenda List calendar events in a time range
31-
files List or search OneDrive files
32-
contacts List or search contacts
33-
sites Search SharePoint / org sites
34-
help Show this message
28+
login Start device-code login and save tokens locally
29+
import-op Import an existing Microsoft account from 1Password
30+
profiles List configured profiles
31+
whoami Show the current Graph account
32+
mail List mail with optional filters
33+
agenda List calendar events in a time range
34+
files List or search OneDrive files
35+
contacts List or search contacts
36+
sites Search SharePoint / org sites
37+
help Show this message
3538
`
3639

3740
func main() {
3841
if err := run(os.Args[1:]); err != nil {
42+
if errors.Is(err, errHelpShown) {
43+
os.Exit(0)
44+
}
3945
fmt.Fprintln(os.Stderr, "error:", err)
4046
os.Exit(1)
4147
}
@@ -47,10 +53,10 @@ func run(args []string) error {
4753
return err
4854
}
4955
if len(rest) == 0 {
50-
return usage()
56+
return showUsage(true, "")
5157
}
5258
if rest[0] == "help" || rest[0] == "--help" || rest[0] == "-h" {
53-
return usage()
59+
return showUsage(false, "")
5460
}
5561

5662
s, err := store.Open("")
@@ -81,7 +87,7 @@ func run(args []string) error {
8187
case "sites":
8288
return cmdSites(s, g, rest)
8389
default:
84-
return usage()
90+
return showUsage(true, fmt.Sprintf("unknown command %q", cmd))
8591
}
8692
}
8793

@@ -117,8 +123,19 @@ func parseGlobals(args []string) (globalFlags, []string, error) {
117123
return g, out, nil
118124
}
119125

120-
func usage() error {
121-
return fmt.Errorf(usageText)
126+
func showUsage(asError bool, prefix string) error {
127+
if asError {
128+
if prefix != "" {
129+
return fmt.Errorf("%s\n\n%s", prefix, usageText)
130+
}
131+
return fmt.Errorf(usageText)
132+
}
133+
if prefix != "" {
134+
fmt.Fprintln(os.Stdout, prefix)
135+
fmt.Fprintln(os.Stdout)
136+
}
137+
_, _ = fmt.Fprint(os.Stdout, usageText)
138+
return errHelpShown
122139
}
123140

124141
func cmdLogin(s *store.Store, g globalFlags, args []string) error {
@@ -200,6 +217,7 @@ func cmdMail(s *store.Store, g globalFlags, args []string) error {
200217
top := fs.Int("top", 10, "maximum number of messages")
201218
sender := fs.String("sender", "", "exact sender email filter")
202219
query := fs.String("query", "", "Graph search expression text")
220+
subject := fs.String("subject", "", "case-insensitive subject substring filter applied client-side")
203221
since := fs.String("since", "", "received since RFC3339 timestamp")
204222
folder := fs.String("folder", "inbox", "well-known mail folder or folder id")
205223
unread := fs.Bool("unread", false, "only include unread messages")
@@ -236,6 +254,9 @@ func cmdMail(s *store.Store, g globalFlags, args []string) error {
236254
if err != nil {
237255
return err
238256
}
257+
if *subject != "" {
258+
data["value"] = filterMailBySubject(data["value"], *subject)
259+
}
239260
return emit(g, data)
240261
}
241262

@@ -283,12 +304,16 @@ func cmdFiles(s *store.Store, g globalFlags, args []string) error {
283304
top := fs.Int("top", 25, "maximum number of items")
284305
path := fs.String("path", "", "folder path to list")
285306
query := fs.String("query", "", "search query")
307+
kind := fs.String("kind", "all", "all|files|folders")
286308
if err := fs.Parse(args); err != nil {
287309
return err
288310
}
289311
if err := requirePositive("--top", *top); err != nil {
290312
return err
291313
}
314+
if *kind != "all" && *kind != "files" && *kind != "folders" {
315+
return fmt.Errorf("--kind must be all, files, or folders")
316+
}
292317
endpoint := "/me/drive/root/children"
293318
params := map[string]string{"$top": fmt.Sprint(*top), "$select": "id,name,webUrl,file,folder,size,lastModifiedDateTime,parentReference"}
294319
if *query != "" {
@@ -300,6 +325,9 @@ func cmdFiles(s *store.Store, g globalFlags, args []string) error {
300325
if err != nil {
301326
return err
302327
}
328+
if *kind != "all" {
329+
data["value"] = filterDriveItems(data["value"], *kind)
330+
}
303331
return emit(g, data)
304332
}
305333

@@ -375,20 +403,48 @@ func escapePathSegment(v string) string {
375403
return strings.ReplaceAll(v, "'", "''")
376404
}
377405

406+
func filterMailBySubject(v any, q string) []map[string]any {
407+
return filterRows(v, func(row map[string]any) bool {
408+
subject, _ := row["subject"].(string)
409+
return strings.Contains(strings.ToLower(subject), strings.ToLower(q))
410+
})
411+
}
412+
378413
func filterEvents(v any, q string) []map[string]any {
414+
q = strings.ToLower(q)
415+
return filterRows(v, func(row map[string]any) bool {
416+
blob, _ := json.Marshal(row)
417+
return strings.Contains(strings.ToLower(string(blob)), q)
418+
})
419+
}
420+
421+
func filterDriveItems(v any, kind string) []map[string]any {
422+
return filterRows(v, func(row map[string]any) bool {
423+
_, hasFile := row["file"]
424+
_, hasFolder := row["folder"]
425+
switch kind {
426+
case "files":
427+
return hasFile && !hasFolder
428+
case "folders":
429+
return hasFolder
430+
default:
431+
return true
432+
}
433+
})
434+
}
435+
436+
func filterRows(v any, keep func(map[string]any) bool) []map[string]any {
379437
items, ok := v.([]any)
380438
if !ok {
381439
return nil
382440
}
383-
q = strings.ToLower(q)
384441
out := []map[string]any{}
385442
for _, item := range items {
386443
row, ok := item.(map[string]any)
387444
if !ok {
388445
continue
389446
}
390-
blob, _ := json.Marshal(row)
391-
if strings.Contains(strings.ToLower(string(blob)), q) {
447+
if keep(row) {
392448
out = append(out, row)
393449
}
394450
}

cmd/msx/main_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"encoding/json"
5+
"errors"
56
"reflect"
67
"testing"
78
)
@@ -25,6 +26,12 @@ func TestParseGlobalsRejectsInvalidFormat(t *testing.T) {
2526
}
2627
}
2728

29+
func TestShowUsageHelpPath(t *testing.T) {
30+
if err := showUsage(false, ""); !errors.Is(err, errHelpShown) {
31+
t.Fatalf("expected errHelpShown, got %v", err)
32+
}
33+
}
34+
2835
func TestFilterEventsMatchesNestedFieldsCaseInsensitively(t *testing.T) {
2936
in := []any{
3037
map[string]any{"subject": "Dentist", "location": map[string]any{"displayName": "Main Street"}},
@@ -40,6 +47,32 @@ func TestFilterEventsMatchesNestedFieldsCaseInsensitively(t *testing.T) {
4047
}
4148
}
4249

50+
func TestFilterMailBySubject(t *testing.T) {
51+
in := []any{
52+
map[string]any{"subject": "Monthly Invoice"},
53+
map[string]any{"subject": "Dinner"},
54+
}
55+
out := filterMailBySubject(in, "invoice")
56+
if len(out) != 1 || out[0]["subject"] != "Monthly Invoice" {
57+
t.Fatalf("unexpected filtered mail: %#v", out)
58+
}
59+
}
60+
61+
func TestFilterDriveItems(t *testing.T) {
62+
in := []any{
63+
map[string]any{"name": "notes.txt", "file": map[string]any{}},
64+
map[string]any{"name": "docs", "folder": map[string]any{}},
65+
}
66+
files := filterDriveItems(in, "files")
67+
folders := filterDriveItems(in, "folders")
68+
if len(files) != 1 || files[0]["name"] != "notes.txt" {
69+
t.Fatalf("unexpected files filter result: %#v", files)
70+
}
71+
if len(folders) != 1 || folders[0]["name"] != "docs" {
72+
t.Fatalf("unexpected folders filter result: %#v", folders)
73+
}
74+
}
75+
4376
func TestSplitCSVTrimsAndDropsEmptyValues(t *testing.T) {
4477
got := splitCSV(" User.Read, , Mail.Read ,")
4578
want := []string{"User.Read", "Mail.Read"}

0 commit comments

Comments
 (0)