diff --git a/CHANGELOG.md b/CHANGELOG.md index e77c311..bd35a5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org). ## [Unreleased] +### Changed + +- Refreshed the README with a quick-start path, a recorded synthetic TUI demo that + does not expose account data, a WorkIQ comparison, practical command recipes, + and collapsed broker implementation details. + +### Added + +- `gh msft demo` launches the interactive UI with deterministic synthetic mail and + calendar data, without requiring a Microsoft 365 account or WorkIQ setup. + ## [0.4.0] - 2026-08-01 ### Added diff --git a/Makefile b/Makefile index 61f6685..ff98e02 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help check-go-version build run install-local relink-local test test-race coverage ci lint fmt tidy clean +.PHONY: help check-go-version build run demo-gif install-local relink-local test test-race coverage ci lint fmt tidy clean .DEFAULT_GOAL := help # ── Change this to your extension name (without the gh- prefix) ── @@ -34,6 +34,7 @@ help: @echo "" @echo " make build Build ./$(BINARY)" @echo " make run Build and run locally" + @echo " make demo-gif Build and record docs/demo.gif with VHS" @echo " make install-local Build and install extension from current checkout" @echo " make relink-local Reinstall local extension link" @echo " make test Run unit tests" @@ -52,6 +53,9 @@ build: check-go-version run: build ./$(BINARY) +demo-gif: build + vhs docs/demo.tape + install-local: build @mkdir -p $(dir $(LOCAL_EXTENSION_DIR)) @rm -rf $(LOCAL_EXTENSION_DIR) diff --git a/README.md b/README.md index d85e29a..ef0b7cd 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,44 @@ # gh-msft -> [!WARNING] -> Currently a spike. Not sure if this will be worth it. API subject to change - A [GitHub CLI](https://cli.github.com/) extension to read and triage your Microsoft 365 **mail and calendar** from the terminal. -## How it works (auth) +> [!NOTE] +> Experimental, useful, and evolving quickly. Commands and output shapes documented +> below are the supported surface; the underlying WorkIQ integration may change. + +Your Microsoft 365 inbox and calendar, where `gh` belongs: keyboard-first when +you are at a terminal, deterministic when you need a script. + +## Start here + +```bash +gh extension install maxbeizer/gh-msft +gh msft accept-eula # run once to accept the WorkIQ EULA +gh msft # open the interactive inbox and calendar +``` + +The first command that reads Microsoft 365 may take a few seconds while WorkIQ +starts. Later commands reuse the local connection. + +The demo below is an actual recording of `gh msft demo`, which uses only +deterministic synthetic mail and calendar data: + +![Recording of the gh-msft demo inbox, calendar, and event detail view.](docs/demo.gif) + +Regenerate the recording with `make demo-gif` (requires +[VHS](https://github.com/charmbracelet/vhs)); the source tape is +[`docs/demo.tape`](docs/demo.tape). + +## Why this instead of WorkIQ chat? + +| WorkIQ chat | `gh-msft` | +| --- | --- | +| Ask natural-language questions about Microsoft 365 | Browse mail and upcoming events in a focused terminal UI | +| Conversational answers | Deterministic, scriptable commands for pipes and automation | +| Uses LLM-backed chat | Calls the Microsoft Graph proxy directly, without LLM latency | + +## Setup and authentication `gh-msft` performs **no authentication of its own** and stores **no credentials**. It rides [WorkIQ](https://github.com/microsoft/work-iq): a user-local broker keeps @@ -29,7 +61,8 @@ By default the broker launches `npx -y @microsoft/workiq@latest mcp`. Override w - `WORKIQ_DIRECT_PROCESS=1` — bypass the broker and launch a private WorkIQ process for this command. Use this for diagnosis or when a local broker cannot start. -### WorkIQ broker lifecycle +
+How the local WorkIQ broker works The first mail or calendar command starts a broker in your user cache directory, then later invocations reuse its authenticated WorkIQ connection. The broker listens @@ -53,6 +86,8 @@ If the broker repeatedly fails, run one command with Remove the `gh-msft` directory under your system user cache directory only after all `gh msft` commands have exited; the next invocation recreates it. +
+ ## Installation ```bash @@ -77,6 +112,21 @@ gh msft cal --json # machine-readable output gh msft accept-eula # accept the WorkIQ EULA (run once) gh msft tui # interactive inbox and calendar gh msft tui --cal # start in calendar mode +gh msft demo # interactive UI with synthetic data; no account required +``` + +### Recipes + +```bash +# Read the next three calendar events as JSON. +gh msft cal --top 3 --json | jq '.[] | {subject, start, organizer}' + +# Find the most recent message ID, then inspect its plain-text body. +gh msft mail list --top 1 --json | jq -r '.[0].id' | xargs gh msft mail view + +# Archive every message selected by an external filter. +gh msft mail list --json | jq -r '.[] | select(.from.email | endswith("@news.example")) | .id' \ + | gh msft mail archive --stdin ``` ### Machine-readable output @@ -137,13 +187,6 @@ compact intentionally on narrow terminals. Calendar events are grouped by start day; all-day events, same-day meetings, and multi-day events use distinct time labels so upcoming commitments remain easy to scan. -## Why this over WorkIQ directly? - -WorkIQ's chat answers questions about your mail. `gh-msft` adds what a chat can't: -a fast **interactive TUI inbox**, **deterministic scriptable commands** (pipe to -`jq`, chain with other `gh` commands), and **no LLM latency** — the Graph proxy is -called directly. - ## Development ```bash diff --git a/docs/demo.gif b/docs/demo.gif new file mode 100644 index 0000000..384e6ed Binary files /dev/null and b/docs/demo.gif differ diff --git a/docs/demo.tape b/docs/demo.tape new file mode 100644 index 0000000..c8d8285 --- /dev/null +++ b/docs/demo.tape @@ -0,0 +1,17 @@ +Output docs/demo.gif + +Set FontSize 18 +Set Width 1000 +Set Height 520 +Set Theme "Catppuccin Mocha" +Set TypingSpeed 40ms + +Type "TZ=UTC ./bin/gh-msft demo" +Enter +Sleep 2s +Tab +Sleep 4s +Enter +Sleep 6s +Type "q" +Sleep 2s diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 97dbeda..7854cc0 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -64,6 +64,7 @@ func newRootCmd(factory Factory, runTUI tuiRunner) *cobra.Command { root.AddCommand(newMailCmd(factory)) root.AddCommand(newCalCmd(factory)) root.AddCommand(newEULACmd(factory)) + root.AddCommand(newDemoCmd(runTUI)) tuiCmd := newTUICmd(factory, runTUI) root.AddCommand(tuiCmd) root.RunE = func(cmd *cobra.Command, args []string) error { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index a74df06..177b59c 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -146,6 +146,30 @@ func TestRootCmdExplicitSubcommandsDoNotLaunchTUI(t *testing.T) { } } +func TestDemoCmdLaunchesTUIWithoutFactory(t *testing.T) { + var gotTop int + var gotAll, gotStartCal bool + runTUI := func(_ mail.Provider, _ calendar.Provider, top int, all bool, startCal bool) error { + gotTop = top + gotAll = all + gotStartCal = startCal + return nil + } + factory := func(context.Context) (*Providers, error) { + t.Fatal("demo should not initialize WorkIQ providers") + return nil, nil + } + root := newRootCmd(factory, runTUI) + root.SetArgs([]string{"demo", "--cal"}) + + if err := root.ExecuteContext(context.Background()); err != nil { + t.Fatalf("run demo: %v", err) + } + if gotTop != 50 || gotAll || !gotStartCal { + t.Errorf("demo TUI options = top:%d all:%t startCal:%t, want top:50 all:false startCal:true", gotTop, gotAll, gotStartCal) + } +} + func TestRootCmdHelpAndCompletionDoNotLaunchTUI(t *testing.T) { for _, args := range [][]string{{"--help"}, {"help"}, {"completion", "bash"}} { t.Run(strings.Join(args, " "), func(t *testing.T) { diff --git a/internal/cli/demo.go b/internal/cli/demo.go new file mode 100644 index 0000000..4ac7769 --- /dev/null +++ b/internal/cli/demo.go @@ -0,0 +1,21 @@ +package cli + +import ( + "github.com/maxbeizer/gh-msft/internal/demo" + "github.com/spf13/cobra" +) + +func newDemoCmd(runTUI tuiRunner) *cobra.Command { + var startCal bool + cmd := &cobra.Command{ + Use: "demo", + Short: "Launch the interactive UI with synthetic data", + Long: "Launch the interactive inbox and calendar using deterministic synthetic data. No Microsoft 365 account or WorkIQ setup is required.", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return runTUI(demo.MailProvider{}, demo.CalendarProvider{}, 50, false, startCal) + }, + } + cmd.Flags().BoolVar(&startCal, "cal", false, "start in calendar mode instead of mail mode") + return cmd +} diff --git a/internal/demo/demo.go b/internal/demo/demo.go new file mode 100644 index 0000000..1862cff --- /dev/null +++ b/internal/demo/demo.go @@ -0,0 +1,174 @@ +// Package demo provides deterministic, synthetic data for product demonstrations. +package demo + +import ( + "context" + + "github.com/maxbeizer/gh-msft/internal/calendar" + "github.com/maxbeizer/gh-msft/internal/mail" + "github.com/maxbeizer/gh-msft/internal/mstime" +) + +var demoMailbox = mail.Address{Name: "Casey Example", Email: "casey@example.test"} + +// MailProvider serves static, non-production messages for gh-msft demo. +type MailProvider struct{} + +// ListInbox returns the synthetic inbox in newest-first order. +func (MailProvider) ListInbox(_ context.Context, top int, _ bool) ([]mail.Message, error) { + messages := []mail.Message{ + demoMail("demo-project-update", "Project update", "Avery Chen", "avery@example.test", "2026-08-01T10:42:00Z", false), + demoMail("demo-build-green", "The build is green (please don't look directly at it)", "Build Bot", "build-bot@example.test", "2026-08-01T10:17:00Z", false), + demoMail("demo-plant", "Action required: name the office plant", "Workplace Team", "workplace@example.test", "2026-08-01T09:48:00Z", false), + demoMail("demo-newsletter", "August engineering news", "Contoso Newsletter", "newsletter@example.test", "2026-08-01T09:15:00Z", true), + demoMail("demo-duck", "Fwd: the rubber duck has opinions", "Morgan Lee", "morgan@example.test", "2026-08-01T08:52:00Z", true), + demoMail("demo-lunch", "Lunch next week?", "Jamie Patel", "jamie@example.test", "2026-07-31T16:08:00Z", true), + demoMail("demo-bake-sale", "Bake sale postmortem: zero regrets", "Community Crew", "community@example.test", "2026-07-31T14:25:00Z", true), + demoMail("demo-meeting", "Meeting notes: this could have been a message", "Priya Shah", "priya@example.test", "2026-07-31T11:05:00Z", true), + demoMail("demo-keyboard", "Your keyboard has reached 10,000 steps", "Wellness Bot", "wellness@example.test", "2026-07-30T15:30:00Z", true), + demoMail("demo-ship-it", "Ship it, but perhaps after lunch", "Release Team", "release@example.test", "2026-07-30T12:15:00Z", true), + demoMail("demo-snacks", "Snack drawer inventory: looking optimistic", "Office Manager", "office@example.test", "2026-07-29T16:40:00Z", true), + demoMail("demo-focus", "Focus time is a meeting with future you", "Calendar Concierge", "calendar@example.test", "2026-07-29T09:00:00Z", true), + } + if top > 0 && top < len(messages) { + return messages[:top], nil + } + return messages, nil +} + +// GetDetail returns a synthetic message detail. +func (MailProvider) GetDetail(_ context.Context, id string) (mail.Detail, error) { + message := demoMessage(id) + return mail.NewDetail(message, demoMailBody(id)), nil +} + +// Archive accepts the synthetic archive action without mutating external state. +func (MailProvider) Archive(context.Context, string) error { + return nil +} + +// Body returns a synthetic plain-text message body. +func (MailProvider) Body(_ context.Context, id string) (string, error) { + return demoMailBody(id), nil +} + +func demoMail(id, subject, name, email, received string, isRead bool) mail.Message { + return mail.Message{ + ID: id, + Subject: subject, + From: mail.Address{Name: name, Email: email}, + To: []mail.Address{demoMailbox}, + Received: mstime.Parse(received), + IsRead: isRead, + } +} + +func demoMessage(id string) mail.Message { + messages, _ := MailProvider{}.ListInbox(context.Background(), 0, false) + for _, message := range messages { + if message.ID == id { + return message + } + } + return mail.Message{ID: id, Subject: "Demo message", To: []mail.Address{demoMailbox}} +} + +func demoMailBody(id string) string { + if id == "demo-duck" { + return "The duck recommends adding a test before changing the code.\n\nThe duck is, regrettably, right." + } + if id == "demo-build-green" { + return "All checks passed.\n\nNo one is sure why, so please enjoy this moment responsibly." + } + return "A short, fictional message body for the gh-msft demo.\n\nEverything in this inbox is static, safe to share, and a little more cheerful than a real Monday." +} + +// CalendarProvider serves static, non-production calendar events for gh-msft demo. +type CalendarProvider struct{} + +// Upcoming returns the synthetic calendar in chronological order. +func (CalendarProvider) Upcoming(_ context.Context, top int) ([]calendar.Event, error) { + events := []calendar.Event{ + demoEvent("demo-standup", "Daily standup (bring one fact)", "2026-08-03T14:00:00Z", "2026-08-03T14:15:00Z", "Avery Chen"), + demoEvent("demo-focus", "Focus time: turning coffee into code", "2026-08-03T14:30:00Z", "2026-08-03T15:30:00Z", "Casey Example"), + demoEvent("demo-triage", "Bug triage: nobody panic (yet)", "2026-08-03T16:00:00Z", "2026-08-03T16:30:00Z", "Morgan Lee"), + demoEvent("demo-planning", "Planning session", "2026-08-03T17:00:00Z", "2026-08-03T18:00:00Z", "Jamie Patel"), + demoEvent("demo-meeting-reduction", "Meeting about reducing meetings", "2026-08-04T14:00:00Z", "2026-08-04T14:30:00Z", "Priya Shah"), + demoEvent("demo-design-review", "Design review: make it pop, tastefully", "2026-08-04T16:00:00Z", "2026-08-04T17:00:00Z", "Avery Chen"), + demoEvent("demo-lunch-and-learn", "Lunch and learn: keyboard shortcuts", "2026-08-05T16:00:00Z", "2026-08-05T17:00:00Z", "Build Bot"), + demoEvent("demo-calendar-hold", "Calendar hold: defend this block", "2026-08-06T14:00:00Z", "2026-08-06T15:00:00Z", "Casey Example"), + demoEvent("demo-retro", "Retro: keep, stop, start, snack", "2026-08-07T15:00:00Z", "2026-08-07T16:00:00Z", "Morgan Lee"), + { + ID: "demo-quiet-day", + Subject: "Quiet day (a bold experiment)", + Start: mstime.Parse("2026-08-10T00:00:00Z"), + End: mstime.Parse("2026-08-11T00:00:00Z"), + IsAllDay: true, + Organizer: "Calendar Concierge", + }, + } + if top > 0 && top < len(events) { + return events[:top], nil + } + return events, nil +} + +func demoEvent(id, subject, start, end, organizer string) calendar.Event { + return calendar.Event{ + ID: id, + Subject: subject, + Start: mstime.Parse(start), + End: mstime.Parse(end), + Organizer: organizer, + } +} + +// GetDetail returns a synthetic calendar event detail. +func (CalendarProvider) GetDetail(_ context.Context, id string) (calendar.Detail, error) { + events, _ := CalendarProvider{}.Upcoming(context.Background(), 0) + for _, event := range events { + if event.ID == id { + return calendar.Detail{ + ID: event.ID, + Subject: event.Subject, + Start: event.Start, + End: event.End, + IsAllDay: event.IsAllDay, + Organizer: demoParticipant(event.Organizer), + Attendees: []calendar.Participant{demoMailboxParticipant(), {Name: "Avery Chen", Email: "avery@example.test"}, {Name: "Morgan Lee", Email: "morgan@example.test"}}, + Location: demoEventLocation(id), + Body: demoEventBody(id), + BodyPreview: "A fictional event used only by the gh-msft demo.", + JoinURL: "https://example.test/meeting", + WebLink: "https://example.test/calendar", + IsOnlineMeeting: true, + }, nil + } + } + return calendar.Detail{ID: id, Subject: "Demo event"}, nil +} + +func demoParticipant(name string) calendar.Participant { + return calendar.Participant{Name: name, Email: "calendar@example.test"} +} + +func demoMailboxParticipant() calendar.Participant { + return calendar.Participant{Name: demoMailbox.Name, Email: demoMailbox.Email} +} + +func demoEventLocation(id string) string { + if id == "demo-focus" || id == "demo-calendar-hold" { + return "Do not disturb" + } + return "Conference room (probably)" +} + +func demoEventBody(id string) string { + if id == "demo-standup" { + return "Bring one fact, one blocker, and one opinion about whether tabs are better than spaces.\n\nNo slides. The rubber duck will take notes." + } + if id == "demo-meeting-reduction" { + return "Objective: determine whether this meeting can be replaced by a message.\n\nIronically, we will need a follow-up meeting to decide." + } + return "A fictional event used only by the gh-msft demo.\n\nNo calendar data, meeting links, or attendees come from a real account." +} diff --git a/internal/demo/demo_test.go b/internal/demo/demo_test.go new file mode 100644 index 0000000..a313d3f --- /dev/null +++ b/internal/demo/demo_test.go @@ -0,0 +1,48 @@ +package demo + +import ( + "context" + "testing" +) + +func TestMailProviderFixtures(t *testing.T) { + messages, err := (MailProvider{}).ListInbox(context.Background(), 5, false) + if err != nil { + t.Fatalf("ListInbox() error = %v", err) + } + if len(messages) != 5 { + t.Fatalf("ListInbox() returned %d messages, want 5", len(messages)) + } + if messages[0].ID != "demo-project-update" { + t.Errorf("first message = %q, want %q", messages[0].ID, "demo-project-update") + } + + detail, err := (MailProvider{}).GetDetail(context.Background(), "demo-duck") + if err != nil { + t.Fatalf("GetDetail() error = %v", err) + } + if detail.Body == "" { + t.Error("GetDetail() returned an empty fixture body") + } +} + +func TestCalendarProviderFixtures(t *testing.T) { + events, err := (CalendarProvider{}).Upcoming(context.Background(), 4) + if err != nil { + t.Fatalf("Upcoming() error = %v", err) + } + if len(events) != 4 { + t.Fatalf("Upcoming() returned %d events, want 4", len(events)) + } + if events[0].ID != "demo-standup" { + t.Errorf("first event = %q, want %q", events[0].ID, "demo-standup") + } + + detail, err := (CalendarProvider{}).GetDetail(context.Background(), "demo-standup") + if err != nil { + t.Fatalf("GetDetail() error = %v", err) + } + if len(detail.Attendees) == 0 || detail.Body == "" { + t.Error("GetDetail() returned an incomplete fixture") + } +}