Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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) ──
Expand Down Expand Up @@ -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"
Expand All @@ -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)
Expand Down
67 changes: 55 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
<details>
<summary>How the local WorkIQ broker works</summary>

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
Expand All @@ -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.

</details>

## Installation

```bash
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Binary file added docs/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 17 additions & 0 deletions docs/demo.tape
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
24 changes: 24 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
21 changes: 21 additions & 0 deletions internal/cli/demo.go
Original file line number Diff line number Diff line change
@@ -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
}
174 changes: 174 additions & 0 deletions internal/demo/demo.go
Original file line number Diff line number Diff line change
@@ -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."
}
Loading
Loading