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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ Mail:
mog mail list --max 50 --query "from:alerts@example.com"
mog mail get <message-id>
mog mail send --to dev@contoso.com --subject "Deploy complete" --body "Finished."
mog mail send --to dev@contoso.com --subject "Re: Deploy complete" --quote <message-id>
mog mail send --to dev@contoso.com --subject "Deploy complete" --body "Finished." --dry-run
```

Expand All @@ -195,6 +196,16 @@ Contacts:
```bash
mog contacts list --max 100
mog contacts create --display-name "Jane Doe" --email "jane@contoso.com"
mog contacts create \
--display-name "Jane Doe" \
--email "jane@contoso.com" \
--org "Contoso" \
--title "Program Manager" \
--url "https://contoso.example/jane" \
--note "Customer success lead" \
--custom region=NA \
--custom team=platform
mog contacts update <contact-id> --title "Director" --custom region=EMEA
```

Groups:
Expand Down Expand Up @@ -277,6 +288,14 @@ Current keys:

Profile metadata is stored in config. Tokens and secrets are stored via keychain/keyring backends.

`keyring_backend` supports `auto`, `keychain`, and `file`.

- `auto`: use native OS keychain when available, otherwise file backend.
- `keychain`: require native OS keychain support.
- `file`: store under the local mogcli keyring directory.

`MOG_KEYRING_PASSWORD` is treated as explicitly configured even when empty, so headless runs do not implicitly prompt for keyring passwords.

## Troubleshooting

No active profile:
Expand Down
8 changes: 6 additions & 2 deletions docs/microsoft-port-plan.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# mogcli Microsoft Port Plan (Revision 2)

Date: 2026-02-12
Date: 2026-02-19
Status: implementation in progress (auth wizard, progressive delegated consent, and selective app-only routing implemented)
Audience: Codex agents implementing `mogcli`

## Implementation status update (2026-02-12)
## Implementation status update (2026-02-19)

Implemented in codebase:

Expand All @@ -20,6 +20,10 @@ Implemented in codebase:
10. Task mutation mutability error normalization in `internal/services/tasks/service.go` for built-in/well-known Microsoft To Do list constraints.
11. `--dry-run` previews for write operations in mail, calendar, and onedrive command surfaces.
12. OneDrive local path hardening (`SafeExpandPath`), metadata-rich delete confirmation, and progress output for local file transfers.
13. Keyring backend hardening: `auto|keychain|file` resolution, native macOS keychain support, and explicit empty `MOG_KEYRING_PASSWORD` handling for headless contexts.
14. Calendar update patch safety: attendee updates are split from reminder-field patches to avoid Graph validation conflicts.
15. Contacts create/update now supports richer fields (`org`, `title`, `url`, `note`, `custom`) with deterministic custom-field ordering in command output.
16. Mail send supports quoted replies via `--quote <message-id>` to include source message context automatically.

## 0. Critique of the prior plan

Expand Down
79 changes: 67 additions & 12 deletions internal/cmd/contacts.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,15 @@ func (c *ContactsGetCmd) Run(ctx context.Context) error {
}

type ContactsCreateCmd struct {
DisplayName string `name:"display-name" required:"" help:"Contact display name"`
Email string `name:"email" help:"Primary email"`
MobilePhone string `name:"mobile-phone" help:"Mobile phone"`
User string `name:"user" help:"App-only target user override (UPN or user ID)"`
DisplayName string `name:"display-name" required:"" help:"Contact display name"`
Email string `name:"email" help:"Primary email"`
MobilePhone string `name:"mobile-phone" help:"Mobile phone"`
Org string `name:"org" help:"Organization/company name"`
Title string `name:"title" help:"Job title"`
URL string `name:"url" help:"Business homepage URL"`
Note string `name:"note" help:"Personal note"`
Custom []string `name:"custom" help:"Custom field key=value (repeat or comma-separate)"`
User string `name:"user" help:"App-only target user override (UPN or user ID)"`
}

func (c *ContactsCreateCmd) Run(ctx context.Context) error {
Expand All @@ -103,6 +108,26 @@ func (c *ContactsCreateCmd) Run(ctx context.Context) error {
if strings.TrimSpace(c.MobilePhone) != "" {
payload["mobilePhone"] = c.MobilePhone
}
if strings.TrimSpace(c.Org) != "" {
payload["companyName"] = strings.TrimSpace(c.Org)
}
if strings.TrimSpace(c.Title) != "" {
payload["jobTitle"] = strings.TrimSpace(c.Title)
}
if strings.TrimSpace(c.URL) != "" {
payload["businessHomePage"] = strings.TrimSpace(c.URL)
}
if strings.TrimSpace(c.Note) != "" {
payload["personalNotes"] = strings.TrimSpace(c.Note)
}

customFields, err := contacts.ParseCustomFields(c.Custom)
if err != nil {
return usage(err.Error())
}
if len(customFields) > 0 {
payload["categories"] = contacts.EncodeCustomFieldCategories(customFields)
}

item, err := contacts.New(rt.Graph, targetUser).Create(ctx, payload)
if err != nil {
Expand All @@ -117,11 +142,16 @@ func (c *ContactsCreateCmd) Run(ctx context.Context) error {
}

type ContactsUpdateCmd struct {
ID string `arg:"" required:"" help:"Contact ID"`
DisplayName string `name:"display-name" help:"Contact display name"`
Email string `name:"email" help:"Primary email"`
MobilePhone string `name:"mobile-phone" help:"Mobile phone"`
User string `name:"user" help:"App-only target user override (UPN or user ID)"`
ID string `arg:"" required:"" help:"Contact ID"`
DisplayName string `name:"display-name" help:"Contact display name"`
Email string `name:"email" help:"Primary email"`
MobilePhone string `name:"mobile-phone" help:"Mobile phone"`
Org string `name:"org" help:"Organization/company name"`
Title string `name:"title" help:"Job title"`
URL string `name:"url" help:"Business homepage URL"`
Note string `name:"note" help:"Personal note"`
Custom []string `name:"custom" help:"Custom field key=value (repeat or comma-separate)"`
User string `name:"user" help:"App-only target user override (UPN or user ID)"`
}

func (c *ContactsUpdateCmd) Run(ctx context.Context) error {
Expand All @@ -139,8 +169,22 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context) error {
if strings.TrimSpace(c.MobilePhone) != "" {
payload["mobilePhone"] = c.MobilePhone
}
if len(payload) == 0 {
return usage("provide at least one field to update")
if strings.TrimSpace(c.Org) != "" {
payload["companyName"] = strings.TrimSpace(c.Org)
}
if strings.TrimSpace(c.Title) != "" {
payload["jobTitle"] = strings.TrimSpace(c.Title)
}
if strings.TrimSpace(c.URL) != "" {
payload["businessHomePage"] = strings.TrimSpace(c.URL)
}
if strings.TrimSpace(c.Note) != "" {
payload["personalNotes"] = strings.TrimSpace(c.Note)
}

customFields, err := contacts.ParseCustomFields(c.Custom)
if err != nil {
return usage(err.Error())
}

rt, err := resolveRuntime(ctx, capContactsUpdate)
Expand All @@ -151,7 +195,18 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context) error {
if err != nil {
return err
}
if err := contacts.New(rt.Graph, targetUser).Update(ctx, c.ID, payload); err != nil {
svc := contacts.New(rt.Graph, targetUser)
if len(customFields) > 0 {
existing, getErr := svc.Get(ctx, c.ID)
if getErr != nil {
return getErr
}
payload["categories"] = contacts.MergeCustomFieldCategories(existing["categories"], customFields)
}
if len(payload) == 0 {
return usage("provide at least one field to update")
}
if err := svc.Update(ctx, c.ID, payload); err != nil {
return err
}

Expand Down
80 changes: 80 additions & 0 deletions internal/cmd/contacts_flags_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package cmd

import (
"reflect"
"testing"
)

func TestContactsCreateExtendedFlagsParse(t *testing.T) {
parser, cli, err := newParser("test")
if err != nil {
t.Fatalf("newParser failed: %v", err)
}

args := []string{
"contacts", "create",
"--display-name", "Jane Doe",
"--email", "jane@example.com",
"--mobile-phone", "+1-555-0100",
"--org", "Contoso",
"--title", "Engineer",
"--url", "https://example.com",
"--note", "Team lead",
"--custom", "team=platform",
"--custom", "region=NA",
}
if _, err := parser.Parse(args); err != nil {
t.Fatalf("parse failed: %v", err)
}

if cli.Contacts.Create.Org != "Contoso" {
t.Fatalf("unexpected org: %q", cli.Contacts.Create.Org)
}
if cli.Contacts.Create.Title != "Engineer" {
t.Fatalf("unexpected title: %q", cli.Contacts.Create.Title)
}
if cli.Contacts.Create.URL != "https://example.com" {
t.Fatalf("unexpected url: %q", cli.Contacts.Create.URL)
}
if cli.Contacts.Create.Note != "Team lead" {
t.Fatalf("unexpected note: %q", cli.Contacts.Create.Note)
}
if !reflect.DeepEqual(cli.Contacts.Create.Custom, []string{"team=platform", "region=NA"}) {
t.Fatalf("unexpected custom flags: %#v", cli.Contacts.Create.Custom)
}
}

func TestContactsUpdateExtendedFlagsParse(t *testing.T) {
parser, cli, err := newParser("test")
if err != nil {
t.Fatalf("newParser failed: %v", err)
}

args := []string{
"contacts", "update", "contact-id",
"--org", "Contoso",
"--title", "Manager",
"--url", "https://example.com/profile",
"--note", "Updated notes",
"--custom", "tier=gold",
}
if _, err := parser.Parse(args); err != nil {
t.Fatalf("parse failed: %v", err)
}

if cli.Contacts.Update.Org != "Contoso" {
t.Fatalf("unexpected org: %q", cli.Contacts.Update.Org)
}
if cli.Contacts.Update.Title != "Manager" {
t.Fatalf("unexpected title: %q", cli.Contacts.Update.Title)
}
if cli.Contacts.Update.URL != "https://example.com/profile" {
t.Fatalf("unexpected url: %q", cli.Contacts.Update.URL)
}
if cli.Contacts.Update.Note != "Updated notes" {
t.Fatalf("unexpected note: %q", cli.Contacts.Update.Note)
}
if !reflect.DeepEqual(cli.Contacts.Update.Custom, []string{"tier=gold"}) {
t.Fatalf("unexpected custom flags: %#v", cli.Contacts.Update.Custom)
}
}
Loading