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
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"name": "mgraphctl",
"displayName": "mgraphctl",
"source": "./",
"version": "0.5.0",
"version": "0.5.1",
"description": "Work with Microsoft 365 — Outlook mail and calendar, Teams chats and channels, presence, meetings and transcripts, SharePoint, OneDrive, OneNote, Planner, To Do, people and org chart — through the Microsoft Graph API (Python CLI run with uv).",
"author": {
"name": "Sviatoslav Sviridov",
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin.json",
"name": "mgraphctl",
"version": "0.5.0",
"version": "0.5.1",
"description": "Work with Microsoft 365 — Outlook mail and calendar, Teams chats and channels, presence, meetings and transcripts, SharePoint, OneDrive, OneNote, Planner, To Do, people and org chart — through the Microsoft Graph API (Python CLI run with uv).",
"author": {
"name": "Sviatoslav Sviridov",
Expand Down
28 changes: 28 additions & 0 deletions .claude/ctx-guard.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"version": 1,
"defaultRole": "main",
"roles": {
"main": {
"description": "the one long-running session in this project",
"triggers": [
{ "id": "ctx-soft", "when": { "context": "300k" }, "do": ["note"], "level": "soft" },
{ "id": "ctx-hard", "when": { "context": "400k" }, "do": ["note"], "level": "hard" },
{ "id": "ctx-stop", "when": { "context": "500k", "every": "50k" }, "do": ["note"], "level": "stop" },
{ "id": "limit-soft", "when": { "limit.any": 80 }, "do": ["note"], "level": "soft" },
{ "id": "limit-hard", "when": { "limit.any": 90 }, "do": ["note", "toast"], "level": "hard" },
{ "id": "week-80-early", "when": { "limit.seven_day": 80, "limit.seven_day.resetsIn": "2d" }, "do": ["note", { "deny": ["Agent", "Workflow"] }], "level": "hard", "message": "With {resetsIn} still to the weekly reset, start no new subagents so the rest of the window lasts; do small steps in this session.", "lift": { "do": ["note", "notify"] } },
{ "id": "week-90-early", "when": { "limit.seven_day": 90, "limit.seven_day.resetsIn": "1d" }, "do": ["note", { "deny": ["Agent", "Workflow"] }], "level": "hard", "message": "With {resetsIn} still to the weekly reset, start no new subagents so the rest of the window lasts; do small steps in this session.", "lift": { "do": ["note", "notify"] } },
{ "id": "limit-brake", "when": { "limit.any": 95 }, "do": ["note", "toast", { "deny": ["Agent", "Workflow", "WebSearch", "WebFetch"] }], "level": "stop", "lift": { "do": ["note", "notify"] } },
{ "id": "cache-miss", "when": { "cache.miss": "50k" }, "do": ["toast"] }
],
"handoff": {
"instructions": "Rewrite the whole file. Header: when it was written and the git HEAD, if this is a git repo. Sections: Goal, State, Next action."
},
"restart": {
"mode": "auto",
"prompt": "Carry out Next action without waiting.",
"minContext": "90k"
}
}
}
}
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mgraphctl",
"version": "0.5.0",
"version": "0.5.1",
"description": "Work with Microsoft 365 through the Microsoft Graph API using a bundled Python CLI run with uv.",
"author": {
"name": "Sviatoslav Sviridov",
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,5 @@ __marimo__/

# codegraph index
.codegraph/
.claude/ctx-guard.local.json
.ctx-guard/
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## [0.5.1] — 2026-11-09

- `teams list`, and any `teams` verb given a `TEAM` by display name, check for
`Team.ReadBasic.All` (or another scope Graph accepts for `/me/joinedTeams`) before sending a
request, and stop with `error[MISSING_SCOPE]` and the `login --scopes extended` hint. They used to
accept `Group.Read.All`, which the `default` sign-in holds, and then fail with Graph's
`error[Forbidden]`. A team given by id (GUID) still works with `Group.Read.All`. The skill's
channel-post recipe and teams examples pass the team id, which the user copies from Teams'
*Get link to team* (`groupId`).
- The scope check counts `User.ReadWrite.All`, `Directory.ReadWrite.All` and
`TeamSettings.ReadWrite.All` as holding their `.Read.All` forms.

## [0.5.0] — 2026-11-05

- `calendar list` selects `lastModifiedDateTime`, and takes `--select` to override the `$select`
Expand Down
1 change: 1 addition & 0 deletions VERSIONING.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,4 @@ summarises it.
| `mgraphctl--v0.4.0` | 2026-09-08 | MCP server: `mcp serve` and `mcp tools`, with capability groups, path confinement and the loopback HTTP transport. |
| `mgraphctl--v0.4.1` | 2026-11-05 | `mail list --from`/`--to` values match as OR; `@domain` documented. |
| `mgraphctl--v0.5.0` | 2026-11-05 | `calendar list --select` and `lastModifiedDateTime`; transcript `createdDateTime` in `meetings list`; short Graph timestamp fractions parse. |
| `mgraphctl--v0.5.1` | 2026-11-09 | `teams list` and team-by-name resolution gate the `/me/joinedTeams` scopes up front; ReadWrite user, directory and team-settings scopes imply their read forms. |
10 changes: 6 additions & 4 deletions docs/specs/2026-09-02-mgraphctl-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@ Every command declares `scopes: list[str]`; an entry `"A|B"` means any-of. `auth
| `Chat.ReadWrite` | `Chat.Read`, `Chat.ReadBasic`, `ChatMessage.Send` |
| `Tasks.ReadWrite`, `Notes.ReadWrite`, `Contacts.ReadWrite`, `Presence.ReadWrite`, `MailboxSettings.ReadWrite`, `TeamMember.ReadWrite.All`, `Group.ReadWrite.All` | the matching `.Read` scope |
| `User.Read.All` | `User.ReadBasic.All` |
| `User.ReadWrite.All` | `User.Read.All`, `User.ReadBasic.All` |
| `Directory.ReadWrite.All`, `TeamSettings.ReadWrite.All` | the matching `.Read.All` scope |

Fixture mode (§5.8) substitutes a synthetic token whose `scp` is the union of every scope declared by any command, so the gate never fires in replay.

Expand Down Expand Up @@ -364,7 +366,7 @@ Positional resource arguments accept an id or a human name; resolution is determ
| Argument | Id detection | Name lookup |
|---|---|---|
| mail `--folder` | `all` → `/me/messages` (whole mailbox, Node's default); well-known name (case-insensitive: inbox, drafts, sentitems, deleteditems, junkemail, archive, outbox, clutter, conversationhistory, msgfolderroot) or `len ≥ 40 and no spaces` | exact `displayName` among `GET /me/mailFolders?$top=200` then one level of `childFolders` |
| `TEAM` | GUID regex | case-insensitive `displayName` in `/me/joinedTeams` |
| `TEAM` | GUID regex | case-insensitive `displayName` in `/me/joinedTeams`, gated on the `teams list` scopes (§15 item 12) |
| `CHANNEL` | starts with `19:` | `displayName` in `/teams/{t}/channels` |
| `CHAT` | starts with `19:` | contains `@` → the 1:1 chat with that UPN (§8 chats dm lookup) |
| `USER`/`UPN` | GUID → `/users/{id}`; contains `@` → `/users/{upn}`; `me` → `/me` | otherwise `people users` search when `User.ReadBasic.All` is held, else exit 2 |
Expand Down Expand Up @@ -502,7 +504,7 @@ Legend. **Tier**: P0 = Node parity; P1 = works with the `default` scope set, new

| Verb | Args | Options (default) | Graph call | Scopes | Parity | Tier | Notes |
|---|---|---|---|---|---|---|---|
| `list` | — | — | `GET /me/joinedTeams` (no OData params) | `Team.ReadBasic.All\|Group.Read.All` | 5.6 teams-list | P0 | |
| `list` | — | — | `GET /me/joinedTeams` (no OData params) | `Team.ReadBasic.All\|TeamSettings.Read.All\|User.Read.All\|Directory.Read.All` | 5.6 teams-list | P0 | |
| `get` | `TEAM` | — | `GET /teams/{t}` | `Team.ReadBasic.All\|Group.Read.All` | — | P1 | |
| `members` | `TEAM` | `--limit 100`, `--all` | `GET /teams/{t}/members` | `TeamMember.Read.All` | — | P2 | |
| `channels` | `TEAM` | — | `GET /teams/{t}/channels?$select=id,displayName,description,membershipType` | `Channel.ReadBasic.All\|Group.Read.All` | 5.7 list | P0 | |
Expand Down Expand Up @@ -757,7 +759,7 @@ Sections, in order, with the rule each must state:
| Setup | `uv` is the only prerequisite; first run installs (10–40 s, stderr notice). Every example spells out the full `${CLAUDE_PLUGIN_ROOT}/mgraphctl <noun> <verb>` path — no shell variables or aliases, because `allowed-tools` matches the literal command prefix and environment does not persist between Bash calls |
| Login and status | always run `${CLAUDE_PLUGIN_ROOT}/mgraphctl status` first (exit 3 = not logged in). On `NOT_LOGGED_IN`/`CONSENT_REQUIRED`: tell the user the exact command to run in **their own terminal** (`… mgraphctl login`, or `… login --scopes extended`, or the admin-consent URL), stop, and wait; never run `login` yourself, never retry in a loop; `MISSING_SCOPE` → relay the hint verbatim |
| Command cheat-sheet | one block per noun listing verbs with the 1–2 most common flag combos; links to `reference/commands.md` for the full option tables |
| Recipes | inbox triage (`mail list --unread` → `mail read` → `mail mark --read` / `todo from-mail`); schedule a meeting (`calendar availability --users … --start … --end …` → `calendar create --teams … --dry-run` → confirm → run); find a file (`search Q --type driveItem` or `sharepoint url URL`); DM someone (`chats dm USER --body … --dry-run` → confirm → run); transcript to summary (`meetings list --subject … --resolve --with-transcripts` → `meetings transcript MID TID --output` → summarise; `meetings insights` when Copilot-licensed); to-do from mail; post to a channel (`teams list` → `teams channels TEAM` → `teams channel send`) |
| Recipes | inbox triage (`mail list --unread` → `mail read` → `mail mark --read` / `todo from-mail`); schedule a meeting (`calendar availability --users … --start … --end …` → `calendar create --teams … --dry-run` → confirm → run); find a file (`search Q --type driveItem` or `sharepoint url URL`); DM someone (`chats dm USER --body … --dry-run` → confirm → run); transcript to summary (`meetings list --subject … --resolve --with-transcripts` → `meetings transcript MID TID --output` → summarise; `meetings insights` when Copilot-licensed); to-do from mail; post to a channel (team GUID from the user, the `groupId` in Teams' *Get link to team*, or `teams list` on an `extended` sign-in → `teams channels TEAM` → `teams channel send … --dry-run` → confirm → run) |
| Guardrails | (1) before **any** write (`send`, `reply`, `forward`, `create`, `update`, `complete`, `delete`, `move`, `mark`, `respond`, `upload`, `mkdir`, `rename`, `share`, `dm`, `from-mail`, `oof set`, `presence set/clear`, `drafts create/send`, non-GET `api`) run the same command with `--dry-run`, show the user the request, and get explicit confirmation before running it without `--dry-run`; (2) never run `login`; (3) prefer `--json` when parsing output, text when showing the user; (4) respect `--limit`/`--all` caps and quote `truncated`; (5) Microsoft 365 only — decline Slack/Google requests; (6) get today's date from the shell (`date`) and the zone from `--tz`/`MGRAPHCTL_TZ` before computing windows; (7) ids from `mail move` change — re-list after moving |
| Output conventions | JSON envelope shapes; text-mode ordering (messages chronological, JSON newest first); datetimes carry offsets; `truncated` note on stderr |
| Exit codes | the §6.5 table verbatim |
Expand Down Expand Up @@ -807,7 +809,7 @@ Decided without user input; each is reversible before phase 1 starts.
9. `Prefer: IdType="ImmutableId"` is not sent; `mail move` therefore returns a new id (documented). Revisit if the skill needs to cache ids across calls.
10. Downloads overwrite existing files and create parent directories; `--output` never prompts.
11. `meetings insights` probes v1.0 first and falls back to `/beta` on 404, exactly as Node does; it is the only implicit beta call outside `--beta`/`api --beta`.
12. `teams list`/`channels` declare `Team.ReadBasic.All|Group.Read.All` and `Channel.ReadBasic.All|Group.Read.All` because Node proves `Group.Read.All` suffices; `teams members` declares `TeamMember.Read.All` only (P2), to be relaxed if `Group.Read.All` is confirmed sufficient.
12. `teams get`/`channels`/`channel get` declare `Team.ReadBasic.All|Group.Read.All` and `Channel.ReadBasic.All|Group.Read.All`: Graph serves `/teams/{t}` and `/teams/{t}/channels` to `Group.Read.All`. `/me/joinedTeams` it does not (verified 2026-11-09: 403 naming `Team.ReadBasic.All`, `TeamSettings.Read*.All`, `User.Read*.All`, `Directory.Read*.All`), so `teams list` declares the read ones of those, and every verb gates them again before resolving a `TEAM` given by display name; `teams members` declares `TeamMember.Read.All` only (P2), to be relaxed if `Group.Read.All` is confirmed sufficient.
13. `org manager|reports` without a UPN declare `User.Read` (Node proves it works for `/me`); for other users Graph requires `User.Read.All` (admin).
14. `people contacts --search` keeps Node's `$search` with a client-side fallback; `onenote search` passes Graph's error through for work accounts.
15. The Node tests were never in CI, and this plugin's pytest suite is also not in `.gitlab-ci.yml` yet (runners lack `uv`); adding a `uv`-capable job is a follow-up.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "mgraphctl"
version = "0.5.0"
version = "0.5.1"
description = "Microsoft Graph CLI behind the mgraphctl Claude Code skill"
readme = "README.md"
license = "MIT"
Expand Down
32 changes: 17 additions & 15 deletions skills/mgraphctl/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ description: >
allowed-tools: Bash(${CLAUDE_PLUGIN_ROOT}/mgraphctl *)
metadata:
author: Sviatoslav Sviridov
version: "0.5.0"
version: "0.5.1"
---

# Microsoft Graph (Python CLI)
Expand Down Expand Up @@ -136,12 +136,14 @@ ${CLAUDE_PLUGIN_ROOT}/mgraphctl groups list --unified --json
${CLAUDE_PLUGIN_ROOT}/mgraphctl groups members "Design Guild"
```

**teams** — `list`, `get`, `members`, `channels`, `channel get|messages|send`
**teams** — `list`, `get`, `members`, `channels`, `channel get|messages|send`. A team given
by display name needs `extended`; with `default`, pass the team id (GUID): the `groupId` in the
link Teams' *Get link to team* copies.

```bash
${CLAUDE_PLUGIN_ROOT}/mgraphctl teams channels "Platform" --json
${CLAUDE_PLUGIN_ROOT}/mgraphctl teams channel messages "Platform" "General" --limit 20
${CLAUDE_PLUGIN_ROOT}/mgraphctl teams channel messages "Platform" "General" --after -7d --all
${CLAUDE_PLUGIN_ROOT}/mgraphctl teams channels 11111111-1111-4111-8111-111111111111 --json
${CLAUDE_PLUGIN_ROOT}/mgraphctl teams channel messages 11111111-1111-4111-8111-111111111111 "General" --limit 20
${CLAUDE_PLUGIN_ROOT}/mgraphctl teams channel messages 11111111-1111-4111-8111-111111111111 "General" --after -7d --all
```

**chats** — `list`, `get`, `members`, `messages`, `send`, `dm`, `create`, `search`,
Expand Down Expand Up @@ -238,12 +240,12 @@ ${CLAUDE_PLUGIN_ROOT}/mgraphctl search "budget plan" --type driveItem --json
${CLAUDE_PLUGIN_ROOT}/mgraphctl chats dm anna@example.com --body "Sending the deck now." --dry-run
```

**Post to a channel.** Resolve the team and channel by name first.
**Post to a channel.** Get the team id from the user (the `groupId` in Teams' *Get link to
team*) or, on an `extended` sign-in, from `teams list`; then find the channel by name.

```bash
${CLAUDE_PLUGIN_ROOT}/mgraphctl teams list --json
${CLAUDE_PLUGIN_ROOT}/mgraphctl teams channels "Platform" --json
${CLAUDE_PLUGIN_ROOT}/mgraphctl teams channel send "Platform" "General" --body "Release is out." --dry-run
${CLAUDE_PLUGIN_ROOT}/mgraphctl teams channels 11111111-1111-4111-8111-111111111111 --json
${CLAUDE_PLUGIN_ROOT}/mgraphctl teams channel send 11111111-1111-4111-8111-111111111111 "General" --body "Release is out." --dry-run
```

**Transcript to summary.** Find the meeting by date and subject, then read the transcript.
Expand Down Expand Up @@ -371,15 +373,15 @@ belong to the user's environment.
`login` requests the `default` set — the 23 permissions covering mail read and send, calendars,
files, sites, chats, channel messages, transcripts, people, contacts, notes, tasks and group
membership. `login --scopes extended` adds mail and mailbox writes, presence, directory user
lookup, shared calendars, chat creation, and team and channel listing.
lookup, shared calendars, chat creation, and team listing and team lookup by name.

These verbs need `extended`: `mail mark`, `mail move`, `mail delete`, `mail drafts create`,
`mail drafts send`, `mail rules list`, `mail categories`, `mailbox settings`, `mailbox oof get`,
`mailbox oof set`, `calendar find-times`, `people users`, `teams members`, `chats create`,
`presence get`, `presence set`, `presence clear`. Three more need it only on one path:
`mail send` when the attachments are large enough to take the draft path, `chats dm` when no
1:1 chat exists yet and it has to create one, and `people photo UPN` when the photo belongs to
someone else.
`mailbox oof set`, `calendar find-times`, `people users`, `teams list`, `teams members`,
`chats create`, `presence get`, `presence set`, `presence clear`. Four more need it only on one
path: `mail send` when the attachments are large enough to take the draft path, `chats dm` when
no 1:1 chat exists yet and it has to create one, `people photo UPN` when the photo belongs to
someone else, and any `teams` verb given a team by display name rather than its id.

Three scopes are in neither set and are asked for one at a time with `login --scope <name>`:
`OnlineMeetingRecording.Read.All` for `meetings recordings`, `User.Read.All` for `org manager`,
Expand Down
4 changes: 2 additions & 2 deletions skills/mgraphctl/evals/evals.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@
{
"id": 8,
"prompt": "Post 'Sprint planning at 2pm tomorrow' to the announcements channel in the Engineering team.",
"expected_output": "Resolves the team and channel by name, previews the send with --dry-run, and asks before actually posting.",
"expected_output": "Gets the Engineering team's id: on a default sign-in asks the user for it (the groupId in Teams' Get link to team), or runs teams list on an extended sign-in. Finds the channel by name, previews the send with --dry-run, and asks before actually posting.",
"files": [],
"assertions": [
{
Expand All @@ -129,7 +129,7 @@
},
{
"name": "uses-channel-send",
"description": "The response uses `teams channel send` with the team and channel named."
"description": "The response uses `teams channel send` with the team id (a GUID, or the team name on an extended sign-in) and the channel."
}
]
},
Expand Down
2 changes: 1 addition & 1 deletion skills/mgraphctl/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ as an id; a leading `/` forces a drive path.
| Argument | Read as an id when | Otherwise looked up as |
|---|---|---|
| `mail --folder` | `all` (the whole mailbox), a well-known name (`inbox`, `drafts`, `sentitems`, `deleteditems`, `junkemail`, `archive`, `outbox`, `clutter`, `conversationhistory`, `msgfolderroot`), or 40+ characters with no spaces | exact `displayName` among `/me/mailFolders` and one level of child folders |
| `TEAM` | a GUID | case-insensitive `displayName` in `/me/joinedTeams` |
| `TEAM` | a GUID | case-insensitive `displayName` in `/me/joinedTeams`, which needs `Team.ReadBasic.All` |
| `CHANNEL` | starts with `19:` | `displayName` in the team's channels |
| `CHAT` | starts with `19:` | a value containing `@` finds the 1:1 chat with that user |
| `USER` / `UPN` | a GUID, a value containing `@`, or `me` | a directory search, which needs `User.ReadBasic.All` |
Expand Down
Loading
Loading