diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 923ffd5..0f95f20 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ "name": "mgraphctl", "displayName": "mgraphctl", "source": "./", - "version": "0.2.0", + "version": "0.2.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", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index f9bf23c..6f15c8f 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin.json", "name": "mgraphctl", - "version": "0.2.0", + "version": "0.2.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", diff --git a/CHANGELOG.md b/CHANGELOG.md index 634e3bd..f84bb81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [0.2.1] — 2026-09-06 + +- The skill's command reference is one file per noun. `reference/commands.md` keeps the + conventions every verb shares — argument resolution, the list envelope, paging defaults, exit + codes, environment variables — and indexes `reference/commands/.md`, so answering a + question about one noun reads roughly a tenth of what the single 87 KB file cost. `api` and + `search` move from the `config` section to `top-level.md`, where they belong. +- SKILL.md documents the `config` verbs, which the cheat-sheet had omitted, and no longer + compares the CLI with the Node `msgraph` skill: the "Differences from" section is gone, as are + the P0/P1/P2 parity tiers. Where a verb needs more than the `default` scope set it now says so + in place, as `Beyond \`default\`:`; a verb without that line works with `default`. +- The README links the skill's command documentation. + ## [0.2.0] — 2026-09-06 - The plugin runs under the Claude desktop app. The supported Python floor drops to 3.10 diff --git a/CLAUDE.md b/CLAUDE.md index 4379cc4..941b077 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,8 @@ src/mgraphctl/ config.py errors.py fixtures.py skills/mgraphctl/ SKILL.md # the skill (<=400 lines; every verb via the shim) - reference/commands.md # one `### noun verb` heading per registered verb + reference/commands.md # shared conventions + index of the per-noun files + reference/commands/.md # one `### noun verb` heading per registered verb evals/evals.json tests/ # test_cli_.py per noun; @covers ties tests to verbs .claude-plugin/ # plugin.json + single-entry marketplace.json @@ -47,8 +48,9 @@ scripts/ # stdlib-only helpers CI runs: scan_secrets.py, e ## Invariants the tests enforce -- Every registered verb has a `@covers` test, a `--json` flag, and a heading in - `reference/commands.md` (`test_surface.py`, `test_docs.py`). +- Every registered verb has a `@covers` test, a `--json` flag, and a heading in some + `reference/commands/.md`, which `reference/commands.md` links (`test_surface.py`, + `test_docs.py`). - The five version strings agree and are bare `X.Y.Z` (`test_version.py`). - SKILL.md frontmatter follows the Agent Skills rules: name pattern, description <= 1024 chars, no XML-shaped tags (`test_docs.py`). diff --git a/README.md b/README.md index beda9a4..16fdc6b 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,22 @@ uv run --project --frozen --no-dev mgraphctl login Every other command reads the cached sign-in silently and never opens a browser or a device-code prompt — a missing or expired token fails with an actionable hint instead. +## Commands + +All 123 verbs are documented inside the skill, and `--help` works at every level. + +| Where | What | +|---|---| +| [`skills/mgraphctl/SKILL.md`](skills/mgraphctl/SKILL.md) | the skill itself: setup, a cheat-sheet of every noun and its verbs, recipes, guardrails, exit codes | +| [`skills/mgraphctl/reference/commands.md`](skills/mgraphctl/reference/commands.md) | the conventions every verb shares — argument resolution, the list envelope, paging defaults, exit codes, environment variables — and the index of the files below | +| [`skills/mgraphctl/reference/commands/`](skills/mgraphctl/reference/commands/) | one file per noun (`mail.md`, `calendar.md`, `teams.md`, …): each verb's options, the Graph call it makes and the scopes it needs | + +```bash +${CLAUDE_PLUGIN_ROOT}/mgraphctl --help +${CLAUDE_PLUGIN_ROOT}/mgraphctl mail --help +${CLAUDE_PLUGIN_ROOT}/mgraphctl mail list --help +``` + ## Where things live - Virtual environment: under `${CLAUDE_PLUGIN_DATA}` when Claude Code sets it, otherwise diff --git a/VERSIONING.md b/VERSIONING.md index 0565ad9..b3e53d2 100644 --- a/VERSIONING.md +++ b/VERSIONING.md @@ -72,3 +72,4 @@ summarises it. |-----|------|---------| | `mgraphctl--v0.1.0` | 2026-09-05 | Initial release: parity with `msgraph`, config file, keychain token storage, client id guard. | | `mgraphctl--v0.2.0` | 2026-09-06 | Python floor drops to 3.10, so the plugin runs under the Claude desktop app. | +| `mgraphctl--v0.2.1` | 2026-09-06 | Command reference split into one file per noun. | diff --git a/pyproject.toml b/pyproject.toml index aedc1a7..572ab2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mgraphctl" -version = "0.2.0" +version = "0.2.1" description = "Microsoft Graph CLI behind the mgraphctl Claude Code skill" readme = "README.md" license = "MIT" diff --git a/skills/mgraphctl/SKILL.md b/skills/mgraphctl/SKILL.md index ea53af7..34d5b58 100644 --- a/skills/mgraphctl/SKILL.md +++ b/skills/mgraphctl/SKILL.md @@ -14,14 +14,15 @@ description: > allowed-tools: Bash(${CLAUDE_PLUGIN_ROOT}/mgraphctl *) metadata: author: Sviatoslav Sviridov - version: "0.2.0" + version: "0.2.1" --- # Microsoft Graph (Python CLI) Read the user's Microsoft 365 data and, with their confirmation, write to it. Every capability is -one `noun verb` command. `reference/commands.md` in this skill directory has the full option table, -Graph call, scopes and tier for all 118 verbs — open it whenever a flag is not on this page. +one `noun verb` command. `reference/commands.md` in this skill directory holds the conventions +they share and indexes `reference/commands/.md`, one file per noun, carrying the option +table, Graph call and scopes for all 123 verbs. Open the one noun's file a task needs. ## Setup @@ -70,12 +71,14 @@ command to run. ## Command cheat-sheet -Two examples per noun. Full option tables are in `reference/commands.md`. +Two examples per noun. Full option tables are in `reference/commands/.md`. -**Top level** — `login`, `logout`, `status`, `claims`, `me`, `version`, `api`, `search` +**Top level** — `login`, `logout`, `status`, `claims`, `me`, `version`, `api`, `search`, and +`config path|show|init|set|unset`, which reads the user's setup — never change it unasked. ```bash ${CLAUDE_PLUGIN_ROOT}/mgraphctl me --json +${CLAUDE_PLUGIN_ROOT}/mgraphctl config show --json ${CLAUDE_PLUGIN_ROOT}/mgraphctl search "quarterly plan" --type driveItem ``` @@ -240,9 +243,8 @@ ${CLAUDE_PLUGIN_ROOT}/mgraphctl meetings list --start -1d --subject standup --re ${CLAUDE_PLUGIN_ROOT}/mgraphctl meetings transcript MEETINGID TRANSCRIPTID --output /tmp/standup.txt ``` -Summarise the saved file. With a Microsoft 365 Copilot licence, ask Graph for the recap instead — -this is Node's `transcripts --insights`, in two steps: resolve the meeting id with `meetings list ---subject … --resolve`, then +Summarise the saved file. With a Microsoft 365 Copilot licence, ask Graph for the recap instead, +in two steps: resolve the meeting id with `meetings list --subject … --resolve`, then ```bash ${CLAUDE_PLUGIN_ROOT}/mgraphctl meetings insights MEETINGID --json @@ -275,11 +277,9 @@ ${CLAUDE_PLUGIN_ROOT}/mgraphctl people search "Anna" --json `onedrive mkdir`, `onedrive move`, `onedrive rename`, `onedrive delete`, `onedrive share`, `sharepoint upload`, `onenote create`, `planner create`, `planner update`, `planner complete`, `planner delete`, `todo create`, `todo update`, `todo complete`, `todo delete`, - `todo from-mail`, and any `api` call whose method is not GET. `--dry-run` withholds the - write; the GET lookups that turn names into ids (folder, calendar, team/channel, chat by - UPN, section, plan/bucket, To Do list, assignee UPN, and the message read by - `todo from-mail`) still run — pass ids (`id:`, GUID, `19:…`) for a fully offline dry run. - `chats dm --dry-run` alone makes no request. + `todo from-mail`, and any `api` call whose method is not GET. `--dry-run` withholds the write, + but the GET lookups that turn names into ids still run; pass ids (`id:`, GUID, `19:…`) for a + fully offline dry run. `reference/commands.md` lists which lookups those are. 2. **Never run `login`.** Give the user the command and wait. No data command ever opens a browser. 3. **Use `--json` when you are parsing, text when you are showing the user.** Text tables are made for reading; JSON is stable and complete. @@ -353,8 +353,8 @@ Errors are one stderr block: `error[]: `, an optional `request-id Each of these (except `NO_COLOR`, `COLUMNS` and the proxies) can also live in `~/.mgraphctl/config.toml` as the name without `MGRAPHCTL_`, lower-cased; flag beats env beats -file. `config show` prints every effective value and its source; `config set KEY VALUE` edits one. -Do not set these yourself; they belong to the user's environment. +file. `config show` prints every effective value and its source. Do not set these yourself; they +belong to the user's environment. ## Scopes and consent @@ -379,20 +379,3 @@ Three scopes are in neither set and are asked for one at a time with `login --sc Many tenants require an administrator to consent once before any of this works. `CONSENT_REQUIRED` prints the admin-consent URL — pass it to the user for their administrator. You cannot grant it, and retrying will not help. - -## Differences from the `msgraph` (Node) skill - -What changed: - -- Node's flag-driven modes became `noun verb`: `emails --read ID` is `mail read ID`, - `calendar --create` is `calendar create`, `teams --dm` is `chats dm`, `channels --team-id T - --send` is `teams channel send TEAM CHANNEL --body`. -- `sharepoint --file-url URL` is `sharepoint url URL`; Node's `--dry-run` there is now `--info`. -- The whole `transcripts` mode is the `meetings` noun: `transcripts --meeting M --transcript T` is - `meetings transcript M T`, and `transcripts --insights` is `meetings list --subject … --resolve` - followed by `meetings insights MEETINGID`. -- `mail list` defaults to the Inbox, where Node listed the whole mailbox — pass `--folder all` for - the old behaviour. -- Exit codes differ: auth failures are 3 and not-found is 4, where Node often exited 0 or 2. Check - the exit code, not just the text. -- `org` has no summary mode; compose it from `org manager`, `org reports` and `people search`. diff --git a/skills/mgraphctl/reference/commands.md b/skills/mgraphctl/reference/commands.md index ab49460..c8f5892 100644 --- a/skills/mgraphctl/reference/commands.md +++ b/skills/mgraphctl/reference/commands.md @@ -1,7 +1,8 @@ # mgraphctl command reference -Every verb the CLI registers, with its options, the Graph call it makes, the scopes it needs, and -its parity tier. `SKILL.md` links here rather than repeating the option tables. +Every verb the CLI registers, with its options, the Graph call it makes and the scopes it needs. +The verbs live one file per noun under `commands/`; this page holds the conventions they share and +the index that points at them. `SKILL.md` links here rather than repeating the option tables. ## How to read this file @@ -16,11 +17,12 @@ ${CLAUDE_PLUGIN_ROOT}/mgraphctl teams channel send TEAM CHANNEL --body "hi" --dr There is no alias and no shorter spelling: `${CLAUDE_PLUGIN_ROOT}` is the only variable that ever appears in a command line. -- **Tier** — P0: parity with the Node `msgraph` skill. P1: new, works with the `default` scope set. - P2: needs `login --scopes extended` or an on-demand `--scope`. - **Scopes** — the scopes the local gate checks before the first request. "A or B" means either satisfies the gate. A scope marked *on-demand* is in neither named set; request it with `login --scope `. +- **Beyond `default`** — this verb, or the part of it the line names, needs more than the + `default` scope set: `login --scopes extended`, or the scope the **Scopes** line marks + *on-demand*, requested with `login --scope `. A verb without the line works with `default`. - **Graph** — the call as sent. `{x}` is a resolved and URL-encoded id. - Every verb accepts `--json`. Every verb that writes accepts `--dry-run`, which prints the request(s) it would send. `--dry-run` withholds the write; the GET lookups that turn names into @@ -45,1932 +47,31 @@ These come *before* the noun: `mgraphctl --tz Europe/Warsaw calendar list`. | `--version` | — | Print `mgraphctl ` and exit 0. | | `--help` | — | Help at every level. Running a noun with no verb prints that noun's help and exits 0. | -## Top-level +## Command files -### `login` +Open the one file the task needs rather than reading every verb. Each file holds that noun's +verbs with their options, Graph call and scopes; the conventions above and the sections +below apply to all of them. -The only command that may open a browser. Run it yourself in your own terminal. - -| Option | Default | Meaning | -|---|---|---| -| `--scopes SET` | `MGRAPHCTL_SCOPES`, else `default` | `default`, `extended`, or a space/comma-separated scope list. | -| `--scope X` | — | One extra on-demand scope. Repeatable. | -| `--force` | off | Sign in again even when a token is cached. | -| `--device-code` | off | Device-code flow, for a host with no browser. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** the msal token endpoint, then `GET /me?$select=id,displayName,userPrincipalName`. -- **Scopes:** none checked (this is what obtains them). -- **Tier:** P0 -- **Notes:** already signed in and no new scopes asked for → prints `Already logged in as: `, - exit 0. Interactive login times out after 300 s (`error[LOGIN_TIMEOUT]`, exit 3). Re-running with - `--scopes extended` on a consented account adds scopes with one consent prompt, no re-login. - The `Cache:` line (JSON `cache`, with `store` = `keyring` or `file`) names the OS keychain or - the cache file, whichever `token_store` resolved to. - -### `logout` - -| Option | Default | Meaning | -|---|---|---| -| `--json` | off | Print JSON instead of text. | - -- **Graph:** none. -- **Scopes:** none. -- **Tier:** P0 -- **Notes:** removes the keychain item and `~/.mgraphctl/token_cache.json`, whichever exist. - Exit 0 whether or not a cache existed; JSON `{"loggedOut", "cache", "store"}`. - -### `status` - -| Option | Default | Meaning | -|---|---|---| -| `--json` | off | Print JSON instead of text. | - -- **Graph:** none — a silent token acquisition, which may refresh. -- **Scopes:** none. -- **Tier:** P0 -- **Notes:** run this first. Logged in → `Logged in as`, `Token expires`, `Scopes`, `Cache`, exit 0. - Not logged in → `error[NOT_LOGGED_IN]` on stderr, exit 3. With `--json` stdout carries - `{"loggedIn": …}` in both states, so it can be parsed without checking the exit code first. - `cache` and `store` say where the sign-in lives: the OS keychain or the file. - -### `claims` - -| Option | Default | Meaning | -|---|---|---| -| `--json` | off | Print the raw token payload. | - -- **Graph:** none — a local JWT decode, no network call. -- **Scopes:** none. -- **Tier:** P0 -- **Notes:** sections IDENTITY, DEVICE, AUTH METHODS, SCOPES. It cannot refresh an expired token; - no cached token → exit 3. - -### `me` - -| Option | Default | Meaning | -|---|---|---| -| `--photo PATH` | — | Save the profile photo to this file instead of printing the profile. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me?$select=id,displayName,userPrincipalName,mail,jobTitle,department,officeLocation,businessPhones,mobilePhone,preferredLanguage`; - `--photo`: `GET /me/photo/$value` (streamed). -- **Scopes:** `User.Read` -- **Tier:** P0 -- **Notes:** a mailbox with no photo returns 404 → exit 4. - -### `version` - -| Option | Default | Meaning | -|---|---|---| -| `--json` | off | Print JSON instead of text. | - -- **Graph:** none. -- **Scopes:** none. -- **Tier:** P1 -- **Notes:** prints `mgraphctl ` with the Python, msal and httpx versions it runs on. - -## `config` - -The optional TOML file at `~/.mgraphctl/config.toml` (or `--config PATH` / `MGRAPHCTL_CONFIG`). -Nothing in it is secret; the token cache stays in the OS keychain or a separate file. - -### `config path` - -| Option | Default | Meaning | -|---|---|---| -| `--json` | off | `{"path", "exists"}`. | - -- **Graph:** none. -- **Scopes:** none. -- **Tier:** P1 -- **Notes:** prints the path the other commands read, whether or not it exists. - -### `config show` - -| Option | Default | Meaning | -|---|---|---| -| `--json` | off | `{"path", "settings": {key: value}, "sources": {key: flag|env|file|default}, "unknownKeys"}`. | - -- **Graph:** none. -- **Scopes:** none. -- **Tier:** P1 -- **Notes:** one row per key with its effective value and where it came from. Keys in the file - that mgraphctl does not know are listed, not rejected. A file that is not valid TOML fails - this and every other command with `error[CONFIG]` (exit 2). - -### `config init` - -| Option | Default | Meaning | -|---|---|---| -| `--force` | off | Overwrite an existing file. | -| `--json` | off | `{"path", "overwritten"}`. | - -- **Graph:** none. -- **Scopes:** none. -- **Tier:** P1 -- **Notes:** writes a template with every key commented out at its default, mode `0600` in a - `0700` directory. Refuses to overwrite without `--force` (`error[USAGE]`). - -### `config set KEY VALUE` - -| Option | Default | Meaning | -|---|---|---| -| `--json` | off | `{"path", "key", "value"}`. | - -- **Graph:** none. -- **Scopes:** none. -- **Tier:** P1 -- **Notes:** replaces the key's line in place (uncommenting a template line), or appends it; - every comment survives. Creates the file when there is none. `KEY` must be one of the config - keys; `debug`, `retries`, `timeout_ms` and `retry_base_ms` take non-negative integers, `tz` - an IANA name and `token_store` one of `auto`, `keyring`, `file` — anything else is - `error[USAGE]` and the file is untouched. - -### `config unset KEY` - -| Option | Default | Meaning | -|---|---|---| -| `--json` | off | `{"path", "key", "removed"}` — `removed` is false when the key was not set. | - -- **Graph:** none. -- **Scopes:** none. -- **Tier:** P1 -- **Notes:** comments the key's line out, so the environment variable or the default applies. - -### `api METHOD PATH` - -The escape hatch: one raw Graph request. `PATH` may be relative (`/me/messages`) or absolute. - -| Option | Default | Meaning | -|---|---|---| -| `--query k=v` | — | Query parameter. Repeatable. | -| `--body JSON\|@FILE` | — | JSON request body, or `@FILE` holding it. | -| `--header k:v` | — | Extra request header. Repeatable. | -| `--beta` | off | Send this call to `/beta`. | -| `--all` | off | Follow `@odata.nextLink` and merge every page's `value`. | -| `--raw` | off | Treat the response as bytes, not JSON. | -| `--output FILE` | — | Write the `--raw` bytes to this file. | -| `--outlook-tz` | off | Send `Prefer: outlook.timezone` for mail/calendar paths. Hidden from `--help`. | -| `--dry-run` | off | Show the request; send nothing. | -| `--json` | off | Accepted, no-op — the body is printed as returned. | - -- **Graph:** exactly the request given. -- **Scopes:** none declared; the local gate is skipped and Graph decides. -- **Tier:** P1 -- **Notes:** the response body is printed as-is; a non-JSON body prints as text unless `--raw`. - Any non-GET `api` call is a write — dry-run it and confirm first. - -### `search Q` - -| Option | Default | Meaning | -|---|---|---| -| `--type TYPE` | `message` | `message`, `event`, `driveItem`, `site`, `list`, `chatMessage`, or `person`. | -| `--after DT` | — | Only hits after this time. | -| `--before DT` | — | Only hits before this time. | -| `--limit N` | 25 | Maximum items. | -| `--all` | off | Fetch every page, cap 200. | -| `--fields a,b` | — | Comma-separated Graph fields to fetch. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `POST /search/query {requests:[{entityTypes:[type], query:{queryString}, from, size:25, fields?}]}`, - paging on `moreResultsAvailable`. -- **Scopes:** checked per `--type` after parsing: `message` → `Mail.Read`; `event` → `Calendars.Read`; - `driveItem`/`site`/`list` → `Sites.Read.All`; `chatMessage` → `Chat.Read`; `person` → `People.Read`. -- **Tier:** P1 -- **Notes:** for `message` the date window is appended to the KQL as `received>=`/`received<=`; for - every other type it is applied client-side. Hits carry a `summary` snippet, not the whole item. - `chatMessage` hits are labelled `chat:` or `channel:/`, exactly as - `chats search` labels them. - -## `mail` - -### `mail list` - -| Option | Default | Meaning | -|---|---|---| -| `--folder NAME\|ID\|all` | `inbox` | Folder name, well-known name, id, or `all` for the whole mailbox. | -| `--unread` | off | Only unread messages. | -| `--from ADDR` | — | Sender address. Repeatable. | -| `--to ADDR` | — | Recipient address. Repeatable. | -| `--search KQL` | — | KQL query. | -| `--after DT` | — | Only messages received after this. | -| `--before DT` | — | Only messages received before this. | -| `--select a,b` | — | `$select` override. | -| `--limit N` | 10 | Maximum items. | -| `--all` | off | Fetch every page, cap 500. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/mailFolders/{folder}/messages` (`--folder all` reads `GET /me/messages`). - **Search mode** (any of `--search`/`--from`/`--to`): `$search=` KQL, `$top=25`, no `$orderby`. - **Filter mode**: `$filter=receivedDateTime ge/le …[ and isRead eq false]`, - `$orderby=receivedDateTime desc`, `$top=50`. Both send - `$select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead,hasAttachments,importance,bodyPreview,conversationId,webLink,inferenceClassification` - and `Prefer: outlook.timezone`. -- **Scopes:** `Mail.Read` -- **Tier:** P0 -- **Notes:** in search mode `--unread` is applied client-side, because KQL has no `isRead` term. - So are `--after`/`--before` when they carry a time of day: KQL's `received` compares on the - calendar date only, so `--after 2026-09-02T14:00` reaches Graph as `received>=2026-09-02` and the - earlier part of that day is dropped here. A date-only bound needs no such pass and gets none. - Both passes run after paging, so a filtered page can be shorter than `--limit` while - `truncated` is still true. - Columns: id, received, flags (`*` unread, `A` attachment, `!` high importance), from, subject. - Node's `emails` mode listed the whole mailbox — pass `--folder all` for that. - -### `mail read ID` - -| Option | Default | Meaning | -|---|---|---| -| `--html` | off | Show the HTML body verbatim instead of text. | -| `--full` | off | Do not truncate the body at 4000 characters. | -| `--headers` | off | Also show the internet message headers. | -| `--output FILE` | — | Write the full body to this file. | -| `--save-attachments DIR` | — | Save every file attachment into this directory. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/messages/{id}?$select=…,body,uniqueBody,replyTo[,internetMessageHeaders]` with - `Prefer: outlook.timezone` and `Prefer: outlook.body-content-type="text"` unless `--html`; - `--save-attachments` adds the attachment list plus `GET …/attachments/{aid}/$value` per file. -- **Scopes:** `Mail.Read` -- **Tier:** P0 -- **Notes:** truncation prints a note on stderr. Item and reference attachments cannot be - downloaded and are skipped with a stderr note. - -### `mail attachments ID` - -| Option | Default | Meaning | -|---|---|---| -| `--download ATTID` | — | Attachment id to download. | -| `--output FILE` | — | Where to write the `--download` file. | -| `--all-attachments` | off | Download every file attachment. | -| `--output-dir DIR` | — | Directory for `--all-attachments`. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/messages/{id}/attachments?$select=id,name,contentType,size,isInline`; - download: `GET …/attachments/{aid}/$value` (streamed). -- **Scopes:** `Mail.Read` -- **Tier:** P0 -- **Notes:** downloading a single itemAttachment or referenceAttachment fails (exit 1); under - `--all-attachments` they are skipped with a stderr note. - -### `mail send` - -| Option | Default | Meaning | -|---|---|---| -| `--to ADDR` | — | Recipient. Repeatable, comma-separated. At least one required. | -| `--cc ADDR` | — | Copy recipient. Repeatable. | -| `--bcc ADDR` | — | Blind copy. Repeatable. | -| `--subject TEXT` | `(no subject)` | Subject line. | -| `--body TEXT` | — | Message body. | -| `--body-file FILE\|-` | — | Body file, or `-` for stdin. | -| `--html` | off | The body is HTML, not plain text. | -| `--attach FILE` | — | File to attach. Repeatable. | -| `--importance LEVEL` | `normal` | `low`, `normal` or `high`. | -| `--save-to-sent` / `--no-save-to-sent` | on | Keep a copy in Sent Items. | -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** **inline path** when the attachments total 2.5 MiB or less: `POST /me/sendMail`. - **Draft path** otherwise: `POST /me/messages`, then per attachment `POST …/attachments` - (under 3 MiB) or `POST …/attachments/createUploadSession` plus chunks, then - `POST /me/messages/{id}/send`. -- **Scopes:** `Mail.Send`; the draft path additionally checks `Mail.ReadWrite` before its first - request. -- **Tier:** P0 (draft path P2) -- **Notes:** write. JSON `{"status":"sent"}`, plus `"draftId"` on the draft path. The dry run names - the path it would take. - -### `mail reply ID` - -| Option | Default | Meaning | -|---|---|---| -| `--body TEXT` | — | Message body. | -| `--body-file FILE\|-` | — | Body file, or `-` for stdin. | -| `--html` | off | The body is HTML. | -| `--reply-all` | off | Reply to everyone on the message. | -| `--to ADDR` | — | Extra recipient. Repeatable. | -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `POST /me/messages/{id}/reply` (or `/replyAll`) with `{comment}`, or - `{message:{body:{contentType:"HTML"}}}` with `--html`, plus `message.toRecipients` for `--to`. -- **Scopes:** `Mail.Send` -- **Tier:** P1 -- **Notes:** write. JSON `{"status":"sent"}`. - -### `mail forward ID` - -| Option | Default | Meaning | -|---|---|---| -| `--to ADDR` | — | Recipient. Repeatable, comma-separated. At least one required. | -| `--body TEXT` | — | Comment to add above the forwarded message. | -| `--html` | off | The body is HTML. | -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `POST /me/messages/{id}/forward {toRecipients, comment}`. -- **Scopes:** `Mail.Send` -- **Tier:** P1 -- **Notes:** write. - -### `mail folders` - -| Option | Default | Meaning | -|---|---|---| -| `--depth N` | 2 | How many levels of the tree to show. | -| `--hidden` | off | Include hidden folders. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/mailFolders?$top=100&$select=id,displayName,parentFolderId,childFolderCount,unreadItemCount,totalItemCount[&includeHiddenFolders=true]`, - then `…/{id}/childFolders` for each folder with children, down to `--depth`. -- **Scopes:** `Mail.Read` -- **Tier:** P1 -- **Notes:** text indents each level by two spaces; JSON nests them under `children`. - -### `mail mark ID...` - -| Option | Default | Meaning | -|---|---|---| -| `--read` / `--unread` | — | Mark read or unread. | -| `--flag` / `--unflag` / `--flag-complete` | — | Follow-up flag state. | -| `--category X` | — | Category to set. Repeatable. | -| `--clear-categories` | off | Remove every category. | -| `--importance LEVEL` | — | `low`, `normal` or `high`. | -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `PATCH /me/messages/{id} {isRead, flag:{flagStatus}, categories, importance}`; more than - one id goes through `POST /$batch` in chunks of 20. -- **Scopes:** `Mail.ReadWrite` -- **Tier:** P2 -- **Notes:** write. Takes any number of ids. JSON is the list envelope even for a single id. - -### `mail move ID` - -| Option | Default | Meaning | -|---|---|---| -| `--folder NAME\|ID` | — | Destination folder. Required. | -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `POST /me/messages/{id}/move {destinationId}`. -- **Scopes:** `Mail.ReadWrite` -- **Tier:** P2 -- **Notes:** write. Returns the **new** message: the id changes, so any id captured before the move - is dead. Re-list after moving. - -### `mail delete ID` - -| Option | Default | Meaning | -|---|---|---| -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `DELETE /me/messages/{id}`. -- **Scopes:** `Mail.ReadWrite` -- **Tier:** P2 -- **Notes:** write. A soft delete — Outlook moves the message to Deleted Items. JSON - `{"status":"deleted","id":…}`. - -### `mail drafts list` - -| Option | Default | Meaning | -|---|---|---| -| `--limit N` | 20 | Maximum items. | -| `--all` | off | Fetch every page, cap 500. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/mailFolders/drafts/messages?$select=…&$orderby=lastModifiedDateTime desc`. -- **Scopes:** `Mail.Read` -- **Tier:** P1 - -### `mail drafts create` - -The `mail send` options, minus `--save-to-sent`. - -| Option | Default | Meaning | -|---|---|---| -| `--to` / `--cc` / `--bcc ADDR` | — | Recipients. Repeatable. | -| `--subject TEXT` | — | Subject line. | -| `--body TEXT` / `--body-file FILE\|-` | — | Body, or a file (`-` for stdin). | -| `--html` | off | The body is HTML. | -| `--attach FILE` | — | File to attach. Repeatable. | -| `--importance LEVEL` | `normal` | `low`, `normal` or `high`. | -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `POST /me/messages {subject, body, toRecipients, …}` plus the same attachment steps as - `mail send`. -- **Scopes:** `Mail.ReadWrite` -- **Tier:** P2 -- **Notes:** write. Returns the draft, including the id `mail drafts send` needs. - -### `mail drafts send ID` - -| Option | Default | Meaning | -|---|---|---| -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `POST /me/messages/{id}/send`. -- **Scopes:** `Mail.ReadWrite`, `Mail.Send` -- **Tier:** P2 -- **Notes:** write. - -### `mail rules list` - -| Option | Default | Meaning | -|---|---|---| -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/mailFolders/inbox/messageRules`. -- **Scopes:** `MailboxSettings.Read` -- **Tier:** P2 -- **Notes:** read-only. Columns: id, sequence, enabled, name, actions summary. - -### `mail categories` - -| Option | Default | Meaning | -|---|---|---| -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/outlook/masterCategories`. -- **Scopes:** `MailboxSettings.Read` -- **Tier:** P2 - -## `mailbox` - -### `mailbox settings` - -| Option | Default | Meaning | -|---|---|---| -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/mailboxSettings`. -- **Scopes:** `MailboxSettings.Read` -- **Tier:** P2 -- **Notes:** shows the mailbox time zone, language, working hours and automatic-replies status. - -### `mailbox oof get` - -| Option | Default | Meaning | -|---|---|---| -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/mailboxSettings/automaticRepliesSetting`. -- **Scopes:** `MailboxSettings.Read` -- **Tier:** P2 - -### `mailbox oof set` - -| Option | Default | Meaning | -|---|---|---| -| `--message TEXT` | — | Internal reply text. Required unless `--clear`. | -| `--external-message TEXT` | `--message` | External reply text. | -| `--start DT` | — | Start of the period. | -| `--end DT` | — | End of the period. | -| `--external AUDIENCE` | `all` | Who outside sees a reply: `all`, `contacts`, `none`. | -| `--internal-only` | off | Same as `--external none`. | -| `--clear` | off | Turn automatic replies off. | -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `PATCH /me/mailboxSettings {automaticRepliesSetting:{status, externalAudience, - scheduledStartDateTime, scheduledEndDateTime, internalReplyMessage, externalReplyMessage}}`. -- **Scopes:** `MailboxSettings.ReadWrite` -- **Tier:** P2 -- **Notes:** write. `--start` with `--end` sets status `scheduled`; otherwise `alwaysEnabled`; - `--clear` sets `disabled`. `--external contacts` maps to `contactsOnly`. Messages go out as HTML. - -### `mailbox focused` - -| Option | Default | Meaning | -|---|---|---| -| `--other` | off | Show the Other inbox instead of Focused. | -| `--after DT` | `-30d` | Only messages received after this. | -| `--limit N` | 25 | Maximum items. | -| `--all` | off | Fetch every page, cap 500. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/mailFolders/inbox/messages` with - `$filter=receivedDateTime ge {after} and inferenceClassification eq 'focused'` (or `'other'`), - `$orderby=receivedDateTime desc`, the `mail list` `$select`, and `Prefer: outlook.timezone`. -- **Scopes:** `Mail.Read` -- **Tier:** P1 -- **Notes:** `receivedDateTime` leads the filter so the `$orderby` stays efficient. - -## `calendar` - -### `calendar list` - -| Option | Default | Meaning | -|---|---|---| -| `--start DT` (alias `--after`) | now | Window start. | -| `--end DT` (alias `--before`) | `--days` out | Window end. | -| `--days N` | 7 | Days from `--start`, when `--end` is not given. | -| `--calendar NAME\|ID` | the default calendar | Which calendar to read. | -| `--search KW` | — | Client-side match on subject, organizer and attendees. | -| `--limit N` | 50 | Maximum items. | -| `--all` | off | Fetch every page, cap 200. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/calendarView` (or `/me/calendars/{id}/calendarView`) with - `startDateTime`, `endDateTime`, - `$select=id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeeting,isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,webLink`, - `$orderby=start/dateTime`, `$top=50`, and `Prefer: outlook.timezone`. -- **Scopes:** `Calendars.Read` -- **Tier:** P0 -- **Notes:** `calendarView` expands recurring series into occurrences. Columns: start, end, markers - (`T` Teams meeting, `X` cancelled), subject, organizer, location, id. - -### `calendar calendars` - -| Option | Default | Meaning | -|---|---|---| -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/calendars?$select=id,name,isDefaultCalendar,canEdit,owner,color`. -- **Scopes:** `Calendars.Read` -- **Tier:** P1 - -### `calendar get ID` - -| Option | Default | Meaning | -|---|---|---| -| `--html` | off | Show the HTML body verbatim. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/events/{id}` with `Prefer: outlook.timezone`, and - `Prefer: outlook.body-content-type="text"` unless `--html`. -- **Scopes:** `Calendars.Read` -- **Tier:** P1 -- **Notes:** shows every attendee with their response status, and the Teams join URL when there is - one. - -### `calendar create` - -| Option | Default | Meaning | -|---|---|---| -| `--subject TEXT` | — | Event title. Required. | -| `--start DT` | — | Start. Required. | -| `--end DT` | `--start` + `--duration` | End. | -| `--duration D` | `30m` | Length, when `--end` is not given. | -| `--all-day` / `--no-all-day` | off | All-day event. | -| `--attendees ADDR` | — | Required attendee. Repeatable. | -| `--optional ADDR` | — | Optional attendee. Repeatable. | -| `--body TEXT` | — | Event body. | -| `--html` | off | The body is HTML. | -| `--location TEXT` | — | Location display name. | -| `--teams` / `--no-teams` | off | Add a Teams online meeting. | -| `--reminder MIN` | — | Minutes before start. | -| `--show-as STATE` | — | `free`, `tentative`, `busy`, `oof`, `workingElsewhere`. | -| `--category X` | — | Category. Repeatable. | -| `--calendar NAME\|ID` | the default calendar | Which calendar to create in. | -| `--transaction-id ID` | a generated uuid4 | Idempotency key. | -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `POST /me/events` (or `/me/calendars/{id}/events`) with - `{subject, start, end, isAllDay, attendees, body, location, isOnlineMeeting, - onlineMeetingProvider:"teamsForBusiness", reminderMinutesBeforeStart, showAs, categories, - transactionId}` and `Prefer: outlook.timezone`. -- **Scopes:** `Calendars.ReadWrite` -- **Tier:** P0 -- **Notes:** write. Returns the event, including `onlineMeeting.joinUrl` with `--teams`. An all-day - event with no `--end` ends at midnight the next day. Graph sends the invitations. - -### `calendar update ID` - -Every `calendar create` option except `--calendar` and `--transaction-id`; all optional. - -| Option | Default | Meaning | -|---|---|---| -| `--subject` / `--start` / `--end` / `--duration` | — | Change these fields. | -| `--all-day` / `--no-all-day` | — | Change the all-day flag. | -| `--attendees` / `--optional ADDR` | — | Replace the attendee list. Repeatable. | -| `--body TEXT` / `--html` | — | Replace the body. | -| `--teams` / `--no-teams` | — | Add or remove the Teams online meeting. | -| `--location` / `--reminder` / `--show-as` / `--category` | — | Change these fields. | -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `PATCH /me/events/{id}` with only the fields given, and `Prefer: outlook.timezone`. -- **Scopes:** `Calendars.ReadWrite` -- **Tier:** P1 -- **Notes:** write. Graph cannot move an event between calendars, so there is no `--calendar`. - Updating a series master updates the whole series. - -### `calendar delete ID` - -| Option | Default | Meaning | -|---|---|---| -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `DELETE /me/events/{id}`. -- **Scopes:** `Calendars.ReadWrite` -- **Tier:** P1 -- **Notes:** write. When the signed-in user is the organizer, Graph sends cancellations to every - attendee. - -### `calendar respond ID accept|decline|tentative` - -| Option | Default | Meaning | -|---|---|---| -| `--comment TEXT` | — | Comment to send with the response. | -| `--send` / `--no-send` | on | Send the response to the organizer. | -| `--propose-start DT` | — | Propose a new start (decline or tentative only). | -| `--propose-end DT` | — | Propose a new end (decline or tentative only). | -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `POST /me/events/{id}/accept` | `/decline` | `/tentativelyAccept` with - `{comment, sendResponse, proposedNewTime}`. -- **Scopes:** `Calendars.ReadWrite` -- **Tier:** P1 -- **Notes:** write. JSON `{"status":"accepted"|"declined"|"tentativelyAccepted"}`. - -### `calendar availability` - -| Option | Default | Meaning | -|---|---|---| -| `--users ADDR` | the signed-in user | Mailbox to check. Repeatable. | -| `--start DT` | now, floored to the interval | Window start. | -| `--end DT` | end of today | Window end. | -| `--interval MIN` | 30 | Minutes per slot (5–1440). | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `POST /me/calendar/getSchedule {schedules, startTime, endTime, - availabilityViewInterval}` with `Prefer: outlook.timezone`; with no `--users`, one - `GET /me?$select=mail,userPrincipalName` first. -- **Scopes:** `Calendars.Read` -- **Tier:** P0 -- **Notes:** text lists each user's busy, tentative, out-of-office and working-elsewhere blocks, - and the free windows computed from `availabilityView`. - -### `calendar find-times` - -| Option | Default | Meaning | -|---|---|---| -| `--attendees ADDR` | — | Attendee. Repeatable. At least one required. | -| `--duration D` | `30m` | Meeting length. | -| `--start DT` | now | Earliest start. | -| `--end DT` | +7d | Latest end. | -| `--max N` | 5 | Maximum candidates. | -| `--domain DOMAIN` | `work` | `work`, `personal` or `unrestricted`. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `POST /me/findMeetingTimes {attendees, timeConstraint:{activityDomain, timeSlots}, - meetingDuration, maxCandidates, returnSuggestionReasons:true}` with `Prefer: outlook.timezone`. -- **Scopes:** `Calendars.Read.Shared` -- **Tier:** P2 -- **Notes:** text shows each suggestion with its confidence and per-attendee availability. When - Graph returns nothing it surfaces `emptySuggestionsReason`. - -## `people` - -### `people search Q` - -| Option | Default | Meaning | -|---|---|---| -| `--limit N` | 20 | Maximum items. | -| `--all` | off | Fetch every page, cap 250. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/people?$search="Q"&$top=50&$select=id,displayName,scoredEmailAddresses,jobTitle,department,companyName,personType,userPrincipalName`. -- **Scopes:** `People.Read` -- **Tier:** P0 -- **Notes:** `/me/people` ranks by relevance to the signed-in user and is in maintenance mode; for a - wider sweep use `search Q --type person` or `people users Q`. - -### `people contacts` - -| Option | Default | Meaning | -|---|---|---| -| `--search Q` | — | Filter by name or email. | -| `--limit N` | 20 | Maximum items. | -| `--all` | off | Fetch every page, cap 250. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/contacts?$top=50&$select=id,displayName,emailAddresses,mobilePhone,businessPhones,jobTitle,companyName[&$search="Q"]`. -- **Scopes:** `Contacts.Read` -- **Tier:** P0 -- **Notes:** when Graph rejects `$search` with a 400, up to 250 contacts are fetched and matched - client-side instead. - -### `people contact ID` - -| Option | Default | Meaning | -|---|---|---| -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/contacts/{id}`. -- **Scopes:** `Contacts.Read` -- **Tier:** P1 - -### `people users Q` - -| Option | Default | Meaning | -|---|---|---| -| `--limit N` | 20 | Maximum items. | -| `--all` | off | Fetch every page, cap 999. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /users?$search="displayName:Q" OR "mail:Q"&$count=true&$top=100&$select=id,displayName,userPrincipalName,mail,jobTitle,department,officeLocation` - with `ConsistencyLevel: eventual`. -- **Scopes:** `User.ReadBasic.All` -- **Tier:** P2 -- **Notes:** a directory search. `$search` matches whole tokens, not substrings — "ann" will not - find "Anna". - -### `people user UPN|ID` - -| Option | Default | Meaning | -|---|---|---| -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /users/{x}?$select=id,displayName,userPrincipalName,mail,jobTitle,department,officeLocation,businessPhones,mobilePhone` - (or `GET /me` for `me`). -- **Scopes:** `User.Read`; resolving a bare display name additionally checks `User.ReadBasic.All`. -- **Tier:** P0 -- **Notes:** accepts a UPN, an object id, `me`, or a display name. - -### `people photo [UPN]` - -| Option | Default | Meaning | -|---|---|---| -| `--output FILE` | `.jpg` | Destination file. | -| `--size WxH` | `96x96` | Photo size. Applies to other users only. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/photo/$value`, or `GET /users/{upn}/photos/{size}/$value` (streamed). -- **Scopes:** `User.Read` for yourself; `User.ReadBasic.All` is checked before reading anyone else. -- **Tier:** P1 (P2 for another user) -- **Notes:** a mailbox with no photo returns 404 → exit 4. - -## `org` - -### `org manager [UPN]` - -| Option | Default | Meaning | -|---|---|---| -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/manager` or `GET /users/{upn}/manager` with - `$select=id,displayName,userPrincipalName,mail,jobTitle,department`. -- **Scopes:** `User.Read`; another user additionally checks `User.Read.All` (*on-demand*). -- **Tier:** P0 (P2 for another user) -- **Notes:** no manager on record → `No manager found`, exit 4. - -### `org reports [UPN]` - -| Option | Default | Meaning | -|---|---|---| -| `--limit N` | 20 | Maximum items. | -| `--all` | off | Fetch every page, cap 500. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/directReports` or `GET /users/{upn}/directReports` with the same `$select`. -- **Scopes:** `User.Read`; another user additionally checks `User.Read.All` (*on-demand*). -- **Tier:** P0 (P2 for another user) - -### `org chain [UPN]` - -| Option | Default | Meaning | -|---|---|---| -| `--max N` | 10 | Maximum levels to climb. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me?$expand=manager($levels=max;$select=id,displayName,userPrincipalName,jobTitle)&$count=true` - with `ConsistencyLevel: eventual`; on 400 or 403 it falls back to walking - `GET /users/{id}/manager` one level at a time, up to `--max`. -- **Scopes:** `User.Read`; the iterative fallback additionally checks `User.Read.All` (*on-demand*). -- **Tier:** P1 -- **Notes:** text prints one line per level, from the user upward; `--json` prints the same order - in the list envelope `{"items": [...], "count": N, "truncated": false}`. - -## `groups` - -### `groups list` - -| Option | Default | Meaning | -|---|---|---| -| `--unified` | off | Only Microsoft 365 (unified) groups. | -| `--limit N` | 20 | Maximum items. | -| `--all` | off | Fetch every page, cap 999. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/memberOf/microsoft.graph.group?$select=id,displayName,mail,groupTypes,description&$top=100`; - `--unified` adds `$filter=groupTypes/any(c:c eq 'Unified')&$count=true` and - `ConsistencyLevel: eventual`. -- **Scopes:** `User.Read`; `Group.Read.All` widens what Graph returns. -- **Tier:** P1 - -### `groups members GROUP` - -| Option | Default | Meaning | -|---|---|---| -| `--limit N` | 20 | Maximum items. | -| `--all` | off | Fetch every page, cap 999. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /groups/{id}/members?$select=id,displayName,userPrincipalName,mail,jobTitle&$top=100`. -- **Scopes:** `Group.Read.All` -- **Tier:** P1 -- **Notes:** `GROUP` may be a group id or a display name from the groups you belong to. - -## `teams` - -### `teams list` - -| Option | Default | Meaning | -|---|---|---| -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/joinedTeams` (Graph accepts no OData parameters on this path). -- **Scopes:** `Team.ReadBasic.All` or `Group.Read.All` -- **Tier:** P0 -- **Notes:** returns every team, unpaged; there is no `--limit`. - -### `teams get TEAM` - -| Option | Default | Meaning | -|---|---|---| -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /teams/{t}`. -- **Scopes:** `Team.ReadBasic.All` or `Group.Read.All` -- **Tier:** P1 -- **Notes:** `TEAM` may be a team id (GUID) or a display name from `teams list`. - -### `teams members TEAM` - -| Option | Default | Meaning | -|---|---|---| -| `--limit N` | 100 | Maximum items. | -| `--all` | off | Fetch every page, cap 999. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /teams/{t}/members`. -- **Scopes:** `TeamMember.Read.All` -- **Tier:** P2 - -### `teams channels TEAM` - -| Option | Default | Meaning | -|---|---|---| -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /teams/{t}/channels?$select=id,displayName,description,membershipType`. -- **Scopes:** `Channel.ReadBasic.All` or `Group.Read.All` -- **Tier:** P0 - -### `teams channel get TEAM CHANNEL` - -| Option | Default | Meaning | -|---|---|---| -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /teams/{t}/channels/{c}`. -- **Scopes:** `Channel.ReadBasic.All` or `Group.Read.All` -- **Tier:** P1 -- **Notes:** `CHANNEL` may be a channel id (starts with `19:`) or a display name. - -### `teams channel messages TEAM CHANNEL` - -| Option | Default | Meaning | -|---|---|---| -| `--full` | off | Print whole bodies, not the first 300 characters. | -| `--with-replies` | off | Expand each message's replies. | -| `--replies MSGID` | — | List the replies to one message instead. | -| `--after DT` | — | Only messages whose reply chain was touched after this. | -| `--before DT` | — | Only messages whose reply chain was touched before this. | -| `--limit N` | 20 | Maximum items. | -| `--all` | off | Fetch every page, cap 200. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /teams/{t}/channels/{c}/messages?$top=50[&$expand=replies]`; - `--replies`: `GET …/messages/{m}/replies?$top=50`, paged and bounded by `--limit`. -- **Scopes:** `ChannelMessage.Read.All` -- **Tier:** P0 -- **Notes:** deleted messages and system placeholders are dropped. Text mode prints oldest first - with HTML bodies converted to Markdown; JSON keeps Graph's order, newest first. - `--after`/`--before` add no query parameter: Graph documents `$top` and `$expand` as the only - ones this endpoint supports, in v1.0 and beta alike, so a `$filter` there would be rejected or — - worse — ignored, and an ignored one would return an unfiltered page dressed up as a window. The - window is applied client-side instead, on the last-modified time of the whole reply chain, which - is the order Graph returns messages in. Because that order is newest first, paging stops at the - first message older than `--after` rather than walking to the cap. `truncated` then counts - only the messages inside the window: reaching the far edge is not a truncation, but a cap - that cut in-window messages short still is. The cap stays 200 for both chat and channel messages — a window that needs more than - 200 messages should be narrowed. `--after`/`--before` do not apply to `--replies` (exit 2), and - `--after` later than `--before` is a usage error. - Without `--after`/`--before` the request is byte-for-byte what it was. - Each message and reply carries `teamId` and `channelId`, which Graph omits on this collection, - so a message can be routed back to its channel. - -### `teams channel send TEAM CHANNEL` - -| Option | Default | Meaning | -|---|---|---| -| `--body TEXT` | — | Message text. | -| `--body-file FILE\|-` | — | File holding the text, or `-` for stdin. | -| `--html` | off | Send the body as HTML. | -| `--subject TEXT` | — | Message subject. | -| `--reply-to MSGID` | — | Reply inside that message's thread. | -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `POST /teams/{t}/channels/{c}/messages`, or `…/messages/{m}/replies` with - `--reply-to`, body `{subject?, body:{contentType:"text"|"html", content}}`. -- **Scopes:** `ChannelMessage.Send` -- **Tier:** P0 -- **Notes:** write. Plain text is sent as `contentType:"text"`, so nothing needs escaping. - -## `chats` - -### `chats list` - -| Option | Default | Meaning | -|---|---|---| -| `--unread` | off | Only chats with unread messages. | -| `--type TYPE` | — | `oneOnOne`, `group` or `meeting`. | -| `--since DT` | — | Only chats whose last message is newer than this. | -| `--limit N` | 20 | Maximum items. | -| `--all` | off | Fetch every page, cap 200. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/chats?$top=50&$expand=members,lastMessagePreview&$orderby=lastMessagePreview/createdDateTime desc&$select=id,topic,chatType,lastUpdatedDateTime,viewpoint,webUrl`. -- **Scopes:** `Chat.Read` -- **Tier:** P0 -- **Notes:** unread is computed client-side from `viewpoint.lastMessageReadDateTime` against - `lastMessagePreview.createdDateTime`, and shows only in text mode. A 1:1 chat is named after the - other member; a group chat shows its topic or its first three members. - `--since` adds no query parameter — the listing is already ordered by - `lastMessagePreview/createdDateTime desc`, so paging simply stops at the first chat whose last - message predates it, and the chats past that boundary are dropped. Reaching the boundary is not - a truncation; the 200 cap still is. A chat with no readable preview timestamp says nothing - about where the boundary is, so it neither stops the fetch nor is dropped from it. - With `--since`, text mode gains a `lastMessage` column - showing the timestamp the bound is measured against; the JSON is unchanged either way, since - `lastMessagePreview` is always expanded. - -### `chats get CHAT` - -| Option | Default | Meaning | -|---|---|---| -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /chats/{id}?$expand=members`. -- **Scopes:** `Chat.Read` -- **Tier:** P1 - -### `chats members CHAT` - -| Option | Default | Meaning | -|---|---|---| -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /chats/{id}/members`. -- **Scopes:** `Chat.Read` -- **Tier:** P1 - -### `chats messages CHAT` - -| Option | Default | Meaning | -|---|---|---| -| `--after DT` | — | Only messages last touched after this. | -| `--before DT` | — | Only messages last touched before this. | -| `--full` | off | Print whole bodies, not the first 300 characters. | -| `--limit N` | 20 | Maximum items. | -| `--all` | off | Fetch every page, cap 200. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /chats/{id}/messages?$top=50&$orderby=createdDateTime desc`; with a window, - `$orderby=lastModifiedDateTime desc&$filter=lastModifiedDateTime gt {after} and lastModifiedDateTime lt {before}`. -- **Scopes:** `Chat.Read` -- **Tier:** P0 -- **Notes:** text prints oldest first with Markdown bodies and `[image: hostedContents/]` - markers; JSON keeps Graph's order, newest first. - A window filters and orders on `lastModifiedDateTime`, not `createdDateTime`: Graph supports - `gt`/`lt` only on that property (`createdDateTime` takes `lt` alone), and ignores a `$filter` - whose property `$orderby` does not also name. So the bounds are the *last touched* time — an - edited message sorts and filters by its edit, and the oldest-first text order follows that - same property rather than creation time. Without a window the order stays - `createdDateTime desc`. `--after` later than `--before` is a usage error. - -### `chats send CHAT` - -| Option | Default | Meaning | -|---|---|---| -| `--body TEXT` | — | Message text. | -| `--body-file FILE\|-` | — | File holding the text, or `-` for stdin. | -| `--html` | off | Send the body as HTML. | -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `POST /chats/{id}/messages {body:{contentType:"text"|"html", content}}`. -- **Scopes:** `ChatMessage.Send` (implied by `Chat.ReadWrite`) -- **Tier:** P0 -- **Notes:** write. - -### `chats dm USER` - -| Option | Default | Meaning | -|---|---|---| -| `--body TEXT` | — | Message text. | -| `--body-file FILE\|-` | — | File holding the text, or `-` for stdin. | -| `--html` | off | Send the body as HTML. | -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /users/{upn}?$select=id,displayName`; then page - `GET /me/chats?$filter=chatType eq 'oneOnOne'&$expand=members&$top=50` (cap 500) looking for a - chat with that user; if there is none, `POST /chats {chatType:"oneOnOne", members:[…]}`; then - `POST /chats/{id}/messages`. -- **Scopes:** `Chat.Read`, `ChatMessage.Send`; creating the chat additionally checks `Chat.Create` - before the `POST`. -- **Tier:** P0 (creating the chat is P2) -- **Notes:** write. The dry run lists both possible paths, with `{chatId}` standing for the id the - create step would produce. - -### `chats create` - -| Option | Default | Meaning | -|---|---|---| -| `--members UPN` | — | Member to add. Repeatable. At least one required. | -| `--topic TEXT` | — | Group chat topic. | -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `POST /chats {chatType: "oneOnOne" (one member, no topic) | "group", topic, members}`. -- **Scopes:** `Chat.Create` -- **Tier:** P2 -- **Notes:** write. - -### `chats search Q` - -| Option | Default | Meaning | -|---|---|---| -| `--after DT` | — | Only hits after this time. | -| `--before DT` | — | Only hits before this time. | -| `--limit N` | 25 | Maximum items. | -| `--all` | off | Fetch every page, cap 200. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `POST /search/query {requests:[{entityTypes:["chatMessage"], query:{queryString}, from, - size:25}]}`, paging on `moreResultsAvailable`. -- **Scopes:** `Chat.Read`; channel hits also need `ChannelMessage.Read.All` to read further. -- **Tier:** P0 -- **Notes:** the date window is applied client-side on `createdDateTime`. Its body is the Search - API's `summary` snippet, not the whole message — read the thread with `chats messages` or - `teams channel messages`. - Each hit carries its routing twice: `where` is the text column, `chat:` or - `channel:/`, and `kind` (`chat`, `channel` or `unknown`) with `chatId`, - `teamId` and `channelId` give a JSON consumer the same thing as ids, so a hit can be followed - into a windowed fetch without parsing that string apart. A hit Graph supplied no routing for — - or only half a `channelIdentity` — is `unknown` with an empty `where`, never - `channel:None/None`. - -### `chats hosted-content CHAT MSGID HCID` | `chats hosted-content URL` - -| Option | Default | Meaning | -|---|---|---| -| `--output FILE` | `teams_hosted_.` | Where to write it. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /chats/{c}/messages/{m}/hostedContents/{h}/$value` (streamed). A - `graph.microsoft.com` URL containing `/hostedContents/` — chat or channel — is used verbatim. -- **Scopes:** `Chat.Read`; a channel URL additionally checks `ChannelMessage.Read.All`. -- **Tier:** P0 -- **Notes:** the extension is sniffed from the magic bytes (png, jpg, gif, webp, pdf, svg, bin). - -## `presence` - -### `presence get [USER...]` - -| Option | Default | Meaning | -|---|---|---| -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/presence`; with users, `POST /communications/getPresencesByUserId {ids}` after - resolving each UPN. -- **Scopes:** `Presence.Read`; reading anyone else additionally checks `Presence.Read.All` - (*on-demand*). -- **Tier:** P2 - -### `presence set available|busy|dnd|brb|away|offline` - -| Option | Default | Meaning | -|---|---|---| -| `--expiration D` | `1h` | How long it lasts (`30m`, `2h`, `PT1H`). | -| `--message TEXT` | — | Status message to show alongside it. | -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `POST /users/{my-oid}/presence/setUserPreferredPresence {availability, activity, - expirationDuration}`; `--message` adds `POST …/presence/setStatusMessage`. -- **Scopes:** `Presence.ReadWrite` -- **Tier:** P2 -- **Notes:** write. The object id comes from the token's `oid` claim, so there is no `/me` call. - -### `presence clear` - -| Option | Default | Meaning | -|---|---|---| -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `POST /users/{my-oid}/presence/clearUserPreferredPresence`. -- **Scopes:** `Presence.ReadWrite` -- **Tier:** P2 -- **Notes:** write. Hands presence back to Teams. - -## `meetings` - -All `meetings` verbs except `list` select the meeting three ways: a positional online-meeting id, -`--join-url URL`, or `--event EVENT_ID`. Give exactly one. - -### `meetings list` - -| Option | Default | Meaning | -|---|---|---| -| `--start DT` | today − 7 days | Start of the window. | -| `--end DT` | end of today | End of the window. | -| `--subject KW` | — | Case-insensitive subject substring. | -| `--resolve` | off | Resolve each event to its online-meeting id. | -| `--with-transcripts` | off | Also list transcript ids (implies `--resolve`). | -| `--limit N` | 50 | Maximum items. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/calendarView?…&$select=id,subject,start,end,organizer,isOnlineMeeting,onlineMeeting&$top=50` - with `Prefer: outlook.timezone`, keeping events that have a join URL; `--resolve` adds a - `POST /$batch` of `GET /me/onlineMeetings?$filter=JoinWebUrl eq '{url}'`; - `--with-transcripts` adds `GET /me/onlineMeetings/{id}/transcripts` per resolved meeting. -- **Scopes:** `Calendars.Read`; `--resolve` additionally checks `OnlineMeetings.Read`, and - `--with-transcripts` `OnlineMeetingTranscript.Read.All`. -- **Tier:** P0 -- **Notes:** columns: start, subject, event id, meeting id (when resolved), transcript ids. - -### `meetings get [MEETING]` - -| Option | Default | Meaning | -|---|---|---| -| `--join-url URL` | — | Select the meeting by its join URL. | -| `--event ID` | — | Select the meeting from a calendar event id. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/onlineMeetings/{id}`, or - `GET /me/onlineMeetings?$filter=JoinWebUrl eq '{url}'`. -- **Scopes:** `OnlineMeetings.Read` -- **Tier:** P1 - -### `meetings transcripts [MEETING]` - -| Option | Default | Meaning | -|---|---|---| -| `--join-url URL` | — | Select the meeting by its join URL. | -| `--event ID` | — | Select the meeting from a calendar event id. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/onlineMeetings/{id}/transcripts`. -- **Scopes:** `OnlineMeetingTranscript.Read.All` -- **Tier:** P0 - -### `meetings transcript [MEETING] TRANSCRIPT_ID` - -| Option | Default | Meaning | -|---|---|---| -| `--join-url URL` | — | Select the meeting by its join URL. | -| `--event ID` | — | Select the meeting from a calendar event id. | -| `--format FMT` | `text` | `text` or `vtt`. | -| `--speakers` | off | Merge each speaker's consecutive cues into one turn. | -| `--output FILE` | — | Write to this file instead of stdout. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/onlineMeetings/{m}/transcripts/{t}/content?$format=text/vtt` (streamed). A 403 - `SpeakerAttributionNotAllowed` is retried with - `Accept: application/vnd.microsoft.graph.transcript+text`. With `--json`, one extra - `GET /me/onlineMeetings/{m}/transcripts` supplies `createdDateTime`. -- **Scopes:** `OnlineMeetingTranscript.Read.All` -- **Tier:** P0 -- **Notes:** with `--join-url` or `--event` the single positional argument is the transcript id; - otherwise give the meeting id and then the transcript id. `text` converts the VTT locally into - `[HH:MM:SS] Speaker: line`, one line per cue. - JSON is `{"meetingId","transcriptId","createdDateTime","format","content"}`. - `format` is **sniffed from the body**, not taken from `--format`: the speaker-attribution - fallback answers in plain text even to a vtt request, and some meeting types answer in vtt to a - text one, so the field says what the content actually is. `--output` reports the same sniffed - value. `createdDateTime` costs one extra listing request, so only `--json` pays it, and a failed - lookup leaves the field `null` rather than failing the command — the content is already in hand. - `--speakers` renders `**Speaker:** text` turns, merging a speaker's consecutive cues into one - and separating turns with a blank line, which is what makes a transcript readable; a cue with no - `` tag continues the turn it falls inside. It respects `--output`, and combined with - `--format vtt` is a usage error rather than a silent override. - -### `meetings insights [MEETING]` - -| Option | Default | Meaning | -|---|---|---| -| `--join-url URL` | — | Select the meeting by its join URL. | -| `--event ID` | — | Select the meeting from a calendar event id. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /copilot/users/{oid}/onlineMeetings/{m}/aiInsights` on `/v1.0`, falling back to - the same path on `/beta` on a 404; then `GET …/aiInsights/{id}` per item. -- **Scopes:** `OnlineMeetingAiInsight.Read.All` -- **Tier:** P0 -- **Notes:** needs a Microsoft 365 Copilot licence. A 403 prints - `AI insights require a Microsoft 365 Copilot license…` and **exits 0**; an empty or missing recap - is likewise a soft message with exit 0 and a `note` in the JSON. Insights can take up to a few - hours after a meeting ends to appear. - -### `meetings recordings [MEETING]` - -| Option | Default | Meaning | -|---|---|---| -| `--join-url URL` | — | Select the meeting by its join URL. | -| `--event ID` | — | Select the meeting from a calendar event id. | -| `--download RID` | — | Recording id to download. | -| `--output FILE` | — | File to write the recording to. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/onlineMeetings/{id}/recordings`; `--download`: - `GET …/recordings/{rid}/content` (streamed). -- **Scopes:** `OnlineMeetingRecording.Read.All` (*on-demand*) -- **Tier:** P2 -- **Notes:** the missing-scope hint names `login --scope OnlineMeetingRecording.Read.All`. - -## `onedrive` - -`ls`, `search`, `get`, `download`, `upload`, `mkdir`, `move`, `rename`, `delete` and `share` accept -`--drive DRIVE_ID` to work against another drive; the base is `/me/drive`, or `/drives/{id}` with -`--drive`. `recent`, `shared-with-me` and `link` are always about the signed-in user's own drive. - -A file or folder is named either by id (`id:ID`, or a bare id-shaped string) or by path -(`/Reports/2026/plan.xlsx`). - -### `onedrive ls [PATH]` - -| Option | Default | Meaning | -|---|---|---| -| `--drive ID` | your drive | Target another drive. | -| `--limit N` | 50 | Maximum items. | -| `--all` | off | Fetch every page, cap 1000. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET {base}/root/children`, or `GET {base}/root:/{path}:/children`, with - `$top=200&$select=id,name,size,lastModifiedDateTime,file,folder,webUrl,parentReference&$orderby=name`. -- **Scopes:** `Files.Read` -- **Tier:** P0 -- **Notes:** columns: type (`d`/`f`), id, size, modified, name. - -### `onedrive search Q` - -| Option | Default | Meaning | -|---|---|---| -| `--drive ID` | your drive | Target another drive. | -| `--shared` | off | Search everything shared with you as well. | -| `--limit N` | 50 | Maximum items. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET {base}/root/search(q='{q}')`, or `GET {base}/search(q='{q}')` with `--shared`, - which also returns remote items. -- **Scopes:** `Files.Read` -- **Tier:** P1 - -### `onedrive get ID|PATH` - -| Option | Default | Meaning | -|---|---|---| -| `--drive ID` | your drive | Target another drive. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET {base}/items/{id}`, or `GET {base}/root:/{path}`. -- **Scopes:** `Files.Read` -- **Tier:** P0 - -### `onedrive download ID|PATH` - -| Option | Default | Meaning | -|---|---|---| -| `--drive ID` | your drive | Target another drive. | -| `--output FILE` | the item name | Destination file. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET {base}/items/{id}/content` or `GET {base}/root:/{path}:/content`; the 302 is - followed without the bearer token. -- **Scopes:** `Files.Read` -- **Tier:** P0 -- **Notes:** writes `.part` and renames it, creating parent directories and overwriting an - existing file. Prints `Downloaded () to `. - -### `onedrive upload FILE` - -| Option | Default | Meaning | -|---|---|---| -| `--drive ID` | your drive | Target another drive. | -| `--dest PATH` | `/` | Destination path; a trailing `/` means a folder. | -| `--conflict MODE` | `replace` | `rename`, `replace` or `fail`. | -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** under 4 MiB, `PUT {base}/root:/{path}:/content?@microsoft.graph.conflictBehavior=…`; - above it, `POST {base}/root:/{path}:/createUploadSession` followed by 10 MiB chunks. -- **Scopes:** `Files.ReadWrite` -- **Tier:** P0 -- **Notes:** write. The dry run shows the file as `{"$file": …, "bytes": N, "contentType": …}`. - -### `onedrive mkdir PATH` - -| Option | Default | Meaning | -|---|---|---| -| `--drive ID` | your drive | Target another drive. | -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `POST {base}/root:/{parent}:/children {name, folder:{}, - "@microsoft.graph.conflictBehavior":"fail"}`. -- **Scopes:** `Files.ReadWrite` -- **Tier:** P1 -- **Notes:** write. Fails rather than silently reusing an existing folder. - -### `onedrive move ID|PATH` - -| Option | Default | Meaning | -|---|---|---| -| `--to FOLDER\|id:ID` | — | Destination folder. Required. | -| `--drive ID` | your drive | Target another drive. | -| `--name NAME` | — | Rename while moving. | -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `PATCH {base}/items/{id} {parentReference:{id}, name?}`, after a `GET` to resolve the - destination folder. -- **Scopes:** `Files.ReadWrite` -- **Tier:** P1 -- **Notes:** write. - -### `onedrive rename ID|PATH NAME` - -| Option | Default | Meaning | -|---|---|---| -| `--drive ID` | your drive | Target another drive. | -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `PATCH {base}/items/{id} {name}`. -- **Scopes:** `Files.ReadWrite` -- **Tier:** P1 -- **Notes:** write. - -### `onedrive delete ID|PATH` - -| Option | Default | Meaning | -|---|---|---| -| `--drive ID` | your drive | Target another drive. | -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `DELETE {base}/items/{id}`. -- **Scopes:** `Files.ReadWrite` -- **Tier:** P1 -- **Notes:** write. Moves the item to the recycle bin. - -### `onedrive share ID|PATH` - -| Option | Default | Meaning | -|---|---|---| -| `--drive ID` | your drive | Target another drive. | -| `--type KIND` | `view` | `view` or `edit`. | -| `--scope WHO` | `organization` | `organization` or `anonymous`. | -| `--expires DT` | — | When the link stops working. | -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `POST {base}/items/{id}/createLink {type, scope, expirationDateTime}`. -- **Scopes:** `Files.ReadWrite` -- **Tier:** P1 -- **Notes:** write. Prints `link.webUrl`. Anonymous links are blocked by policy in many tenants — - a 403 there is a tenant setting, not a missing scope. - -### `onedrive shared-with-me` - -| Option | Default | Meaning | -|---|---|---| -| `--limit N` | 50 | Maximum items. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/drive/sharedWithMe`. -- **Scopes:** `Files.Read.All` or `Sites.Read.All` -- **Tier:** P1 -- **Notes:** Microsoft is retiring this endpoint; a stderr note says so. Each item carries a - `remoteItem` whose `driveId` and `id` are shown, and which `onedrive get --drive` can open. - -### `onedrive recent` - -| Option | Default | Meaning | -|---|---|---| -| `--limit N` | 20 | Maximum items. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/drive/recent`. -- **Scopes:** `Files.Read` -- **Tier:** P1 - -### `onedrive link URL` - -| Option | Default | Meaning | -|---|---|---| -| `--download` | off | Download the item's content. | -| `--output FILE` | the item name | Destination file for `--download`. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /shares/{share_id(url)}/driveItem`; `--download` adds `…/driveItem/content`. -- **Scopes:** `Files.Read` (plus whatever access the link itself grants) -- **Tier:** P1 -- **Notes:** works for OneDrive and SharePoint sharing links alike. - -## `sharepoint` - -`SITE` accepts a site URL, a `host:/sites/name` reference, a composite site id, or a site name to -search for. - -### `sharepoint sites` - -| Option | Default | Meaning | -|---|---|---| -| `--search Q` | — | Search text; `*` matches everything. | -| `--limit N` | 20 | Maximum items. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/followedSites?$select=id,displayName,webUrl`; with `--search`, or when the - followed list is empty, `GET /sites?search={Q or *}&$top={limit}`. -- **Scopes:** `Sites.Read.All` -- **Tier:** P0 - -### `sharepoint site REF` - -| Option | Default | Meaning | -|---|---|---| -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /sites/{ref}?$select=id,displayName,name,webUrl,description`. -- **Scopes:** `Sites.Read.All` -- **Tier:** P0 -- **Notes:** use this to turn a URL a colleague sent into the site id the other verbs take. - -### `sharepoint drives SITE` - -| Option | Default | Meaning | -|---|---|---| -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /sites/{id}/drives?$select=id,name,webUrl,driveType`. -- **Scopes:** `Sites.Read.All` -- **Tier:** P1 - -### `sharepoint ls SITE [PATH]` - -| Option | Default | Meaning | -|---|---|---| -| `--drive NAME\|ID` | the site's default drive | Which document library. | -| `--limit N` | 50 | Maximum items. | -| `--all` | off | Fetch every page, cap 1000. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** as `onedrive ls`, with the base `/sites/{id}/drive` or `/drives/{d}`. -- **Scopes:** `Sites.Read.All` -- **Tier:** P0 -- **Notes:** item ids are printed in full, so they can be fed straight to `sharepoint download`. - -### `sharepoint search Q` - -| Option | Default | Meaning | -|---|---|---| -| `--site SITE` | — | Search within one site's drive instead of everywhere. | -| `--limit N` | 25 | Maximum items. | -| `--all` | off | Fetch every page, cap 200. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** with `--site`, `GET /sites/{id}/drive/root/search(q='{q}')`; without it, - `POST /search/query` with `entityTypes:["driveItem"]` and size 25. -- **Scopes:** `Sites.Read.All` -- **Tier:** P1 - -### `sharepoint download SITE ITEM|PATH` - -| Option | Default | Meaning | -|---|---|---| -| `--output FILE` | the item name | Destination file. | -| `--drive NAME\|ID` | the site's default drive | Which document library. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /sites/{id}/drive/items/{item}/content`, or - `GET /drives/{d}/root:/{path}:/content`. -- **Scopes:** `Sites.Read.All` -- **Tier:** P0 -- **Notes:** reads the **site** drive, not the signed-in user's OneDrive. - -### `sharepoint upload SITE FILE` - -| Option | Default | Meaning | -|---|---|---| -| `--dest PATH` | `/` | Destination path in the library. | -| `--drive NAME\|ID` | the site's default drive | Which document library. | -| `--conflict MODE` | `replace` | `rename`, `replace` or `fail`. | -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** as `onedrive upload`, with the site drive as the base. -- **Scopes:** `Sites.ReadWrite.All` -- **Tier:** P1 -- **Notes:** write. - -### `sharepoint url URL` - -| Option | Default | Meaning | -|---|---|---| -| `--output FILE` | the file's own name | Destination file. | -| `--info` | off | Resolve and print, without downloading. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** first `GET /shares/{share_id(url)}/driveItem`; on a 4xx it parses the URL's host and - `sites`/`teams`/`personal` segment, resolves the site and its drives, picks the drive whose - `webUrl` is the deepest prefix of the file path, and reads - `GET /drives/{d}/root:/{rel}:/content`, with `GET /sites/{id}/drive/root:/{rel}:/content` as a - last resort. -- **Scopes:** `Sites.Read.All` -- **Tier:** P0 -- **Notes:** the fastest way to open a link someone pasted into chat or mail. `--info` prints the - resolution — site id, drive id, path, item — and downloads nothing; JSON includes `resolution`. - A `/personal/` (OneDrive) URL needs the owner to have shared the file with you. - -### `sharepoint lists SITE` - -| Option | Default | Meaning | -|---|---|---| -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /sites/{id}/lists?$select=id,displayName,webUrl,list`. -- **Scopes:** `Sites.Read.All` -- **Tier:** P1 -- **Notes:** system lists are hidden. - -### `sharepoint items SITE LIST` - -| Option | Default | Meaning | -|---|---|---| -| `--fields a,b` | every field | Which columns to fetch. | -| `--filter ODATA` | — | OData filter over `fields/*`. | -| `--limit N` | 50 | Maximum items. | -| `--all` | off | Fetch every page, cap 500. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /sites/{id}/lists/{l}/items?$expand=fields($select=…)&$top=200[&$filter=…]`, with - `Prefer: HonorNonIndexedQueriesWarningMayFailRandomly` when `--filter` is given. -- **Scopes:** `Sites.Read.All` -- **Tier:** P1 -- **Notes:** text shows the first eight fields as columns; use `--json` to see them all. A filter on - an unindexed column may fail intermittently — that is the SharePoint list threshold, not a bug. - -## `onenote` - -### `onenote notebooks` - -| Option | Default | Meaning | -|---|---|---| -| `--limit N` | 50 | Maximum items. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/onenote/notebooks?$top=100&$select=id,displayName,lastModifiedDateTime,links`. -- **Scopes:** `Notes.Read` -- **Tier:** P0 - -### `onenote sections [NOTEBOOK]` - -| Option | Default | Meaning | -|---|---|---| -| `--limit N` | 50 | Maximum items. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/onenote/notebooks/{id}/sections`, or `GET /me/onenote/sections` across every - notebook, with `$top=100&$select=id,displayName,lastModifiedDateTime,parentNotebook`. -- **Scopes:** `Notes.Read` -- **Tier:** P0 - -### `onenote pages SECTION` - -| Option | Default | Meaning | -|---|---|---| -| `--limit N` | 50 | Maximum items. | -| `--all` | off | Fetch every page, cap 500. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/onenote/sections/{id}/pages?$top=100&$select=id,title,lastModifiedDateTime,links&$orderby=lastModifiedDateTime desc`. -- **Scopes:** `Notes.Read` -- **Tier:** P0 - -### `onenote read PAGE` - -| Option | Default | Meaning | -|---|---|---| -| `--html` | off | Print the page's raw HTML instead of Markdown. | -| `--output FILE` | — | Write to this file instead of stdout. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/onenote/pages/{id}?$select=id,title,lastModifiedDateTime,links` plus - `GET /me/onenote/pages/{id}/content?includeIDs=true`. -- **Scopes:** `Notes.Read` -- **Tier:** P0 -- **Notes:** JSON carries `{"id","title","html","markdown"}`. - -### `onenote create` - -| Option | Default | Meaning | -|---|---|---| -| `--section SECTION` | — | Section name or id. Required. | -| `--title TEXT` | — | Page title. Required. | -| `--body TEXT` | — | Page body text. | -| `--body-file FILE\|-` | — | File holding the body, or `-` for stdin. | -| `--html` | off | Treat the body as raw HTML, unescaped. | -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `POST /me/onenote/sections/{id}/pages` with `Content-Type: text/html` and a minimal - HTML document carrying the title and body. -- **Scopes:** `Notes.ReadWrite` -- **Tier:** P0 -- **Notes:** write. The title and body are HTML-escaped unless `--html` is given. - -### `onenote search Q` - -| Option | Default | Meaning | -|---|---|---| -| `--limit N` | 50 | Maximum items. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/onenote/pages?$search={Q}&$top=100&$select=id,title,createdDateTime,parentSection`. -- **Scopes:** `Notes.Read` -- **Tier:** P0 -- **Notes:** Graph documents `$search` here for consumer notebooks only. On a work account it may - return 400 or 501; that error is passed through with the hint to use - `search Q --type driveItem` instead. - -## `planner` - -Planner endpoints accept no OData parameters, so `--limit` slices client-side after the whole -collection has been fetched. - -### `planner plans` - -| Option | Default | Meaning | -|---|---|---| -| `--limit N` | 50 | Maximum items. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/planner/plans`, unioned with the plans of every Microsoft 365 group you belong - to — `GET /me/memberOf/microsoft.graph.group?$filter=groupTypes/any(c:c eq 'Unified')&$count=true` - with `ConsistencyLevel: eventual`, then a `POST /$batch` of `GET /groups/{id}/planner/plans` — - deduplicated by id. -- **Scopes:** `Tasks.ReadWrite`, `Group.Read.All` -- **Tier:** P0 -- **Notes:** text shows the owning group's name next to each plan. - -### `planner plan PLAN` - -| Option | Default | Meaning | -|---|---|---| -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /planner/plans/{id}` plus `GET /planner/plans/{id}/details`. -- **Scopes:** `Tasks.ReadWrite` -- **Tier:** P1 -- **Notes:** JSON is the plan with a `details` member. - -### `planner buckets PLAN` - -| Option | Default | Meaning | -|---|---|---| -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /planner/plans/{id}/buckets`. -- **Scopes:** `Tasks.ReadWrite` -- **Tier:** P0 - -### `planner tasks [PLAN]` - -| Option | Default | Meaning | -|---|---|---| -| `--my` | off | Your tasks across every plan, instead of one plan's tasks. | -| `--bucket NAME\|ID` | — | Only tasks in this bucket. | -| `--include-completed` | off | Also show tasks at 100 %. | -| `--limit N` | 50 | Maximum items. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /planner/plans/{id}/tasks`, or `GET /me/planner/tasks` with `--my`; `--my` adds a - `POST /$batch` of `GET /planner/plans/{planId}` (up to 20 distinct plans) for plan titles, and - bucket names are fetched for the plans involved. -- **Scopes:** `Tasks.ReadWrite` -- **Tier:** P0 -- **Notes:** completed tasks are hidden unless `--include-completed`. Columns: id, percent, - priority, due, bucket, plan (with `--my`), title. - -### `planner task ID` - -| Option | Default | Meaning | -|---|---|---| -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /planner/tasks/{id}` plus `GET /planner/tasks/{id}/details`. -- **Scopes:** `Tasks.ReadWrite` -- **Tier:** P0 -- **Notes:** an error fetching the details is swallowed; the task still prints. - -### `planner create` - -| Option | Default | Meaning | -|---|---|---| -| `--plan PLAN` | — | Plan name or id. Required. | -| `--title TEXT` | — | Task title. Required. | -| `--bucket NAME\|ID` | — | Bucket to file it under. | -| `--due DATE` | — | Due date. | -| `--assign UPN` | — | Assignee. Repeatable. | -| `--priority 0-10` | — | Planner priority. | -| `--description TEXT` | — | Task description. | -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /users/{upn}?$select=id` per assignee, then - `POST /planner/tasks {planId, bucketId, title, dueDateTime, priority, assignments}`; - `--description` adds `GET …/details` for the etag and `PATCH …/details` with `If-Match`. -- **Scopes:** `Tasks.ReadWrite` -- **Tier:** P0 -- **Notes:** write. Assignees are given as UPNs, not object ids. - -### `planner update ID` - -| Option | Default | Meaning | -|---|---|---| -| `--title TEXT` | — | New title. | -| `--due DATE` | — | New due date. | -| `--percent N` | — | Completion: 0, 50 or 100. | -| `--bucket NAME\|ID` | — | Move to another bucket. | -| `--priority 0-10` | — | New priority. | -| `--assign UPN` | — | Add an assignee. Repeatable. | -| `--unassign UPN` | — | Remove an assignee. Repeatable. | -| `--description TEXT` | — | New description. | -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /planner/tasks/{id}` for the etag, then `PATCH /planner/tasks/{id}` with - `If-Match` and `Prefer: return=representation`; a 412 re-reads the etag once and retries. -- **Scopes:** `Tasks.ReadWrite` -- **Tier:** P1 -- **Notes:** write. Planner tracks completion as a percentage; Planner's own UI only ever sets - 0, 50 or 100. - -### `planner complete ID` - -| Option | Default | Meaning | -|---|---|---| -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** the `planner update` request with `percentComplete: 100`. -- **Scopes:** `Tasks.ReadWrite` -- **Tier:** P0 -- **Notes:** write. - -### `planner delete ID` - -| Option | Default | Meaning | -|---|---|---| -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /planner/tasks/{id}` for the etag, then `DELETE /planner/tasks/{id}` with - `If-Match`. -- **Scopes:** `Tasks.ReadWrite` -- **Tier:** P1 -- **Notes:** write. Planner has no recycle bin — a deleted task is gone. - -## `todo` - -`LIST` accepts a list id, the well-known names `defaultList` and `flaggedEmails`, or a list's -display name. - -### `todo lists` - -| Option | Default | Meaning | -|---|---|---| -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/todo/lists?$top=100`. -- **Scopes:** `Tasks.ReadWrite` -- **Tier:** P0 -- **Notes:** shows each list's `wellknownListName` where it has one. - -### `todo tasks LIST` - -| Option | Default | Meaning | -|---|---|---| -| `--include-completed` | off | Also show completed tasks. | -| `--limit N` | 50 | Maximum items. | -| `--all` | off | Fetch every page, cap 500. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/todo/lists/{l}/tasks?$top=100[&$filter=status ne 'completed']` with - `Prefer: outlook.timezone`. -- **Scopes:** `Tasks.ReadWrite` -- **Tier:** P0 -- **Notes:** columns: id, status, importance, due, title. - -### `todo task LIST ID` - -| Option | Default | Meaning | -|---|---|---| -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/todo/lists/{l}/tasks/{t}?$expand=checklistItems,linkedResources` with - `Prefer: outlook.timezone`. -- **Scopes:** `Tasks.ReadWrite` -- **Tier:** P1 - -### `todo create LIST` - -| Option | Default | Meaning | -|---|---|---| -| `--title TEXT` | — | Task title. Required. | -| `--due DT` | — | Due date. | -| `--body TEXT` | — | Task notes. | -| `--importance LEVEL` | — | `low`, `normal` or `high`. | -| `--reminder DT` | — | Reminder time; also turns the reminder on. | -| `--start DT` | — | Start date. | -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `POST /me/todo/lists/{l}/tasks {title, body, importance, dueDateTime, reminderDateTime, - isReminderOn, startDateTime}`, dates sent in the active time zone. -- **Scopes:** `Tasks.ReadWrite` -- **Tier:** P0 -- **Notes:** write. - -### `todo update LIST ID` - -| Option | Default | Meaning | -|---|---|---| -| `--title` / `--due` / `--body` / `--importance` / `--reminder` / `--start` | — | The `todo create` fields. | -| `--status STATE` | — | `notStarted`, `inProgress`, `completed`, `waitingOnOthers`, `deferred`. | -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `PATCH /me/todo/lists/{l}/tasks/{t}` with only the fields given. -- **Scopes:** `Tasks.ReadWrite` -- **Tier:** P1 -- **Notes:** write. - -### `todo complete LIST ID` - -| Option | Default | Meaning | -|---|---|---| -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `PATCH /me/todo/lists/{l}/tasks/{t} {status:"completed"}`. -- **Scopes:** `Tasks.ReadWrite` -- **Tier:** P0 -- **Notes:** write. - -### `todo delete LIST ID` - -| Option | Default | Meaning | -|---|---|---| -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `DELETE /me/todo/lists/{l}/tasks/{t}`. -- **Scopes:** `Tasks.ReadWrite` -- **Tier:** P1 -- **Notes:** write. - -### `todo from-mail LIST MSGID` - -| Option | Default | Meaning | -|---|---|---| -| `--title TEXT` | the message subject | Task title. | -| `--due DT` | — | Due date. | -| `--importance LEVEL` | — | `low`, `normal` or `high`. | -| `--dry-run` | off | Show the request(s); send nothing. | -| `--json` | off | Print JSON instead of text. | - -- **Graph:** `GET /me/messages/{id}?$select=subject,webLink,bodyPreview,from,receivedDateTime`, then - `POST /me/todo/lists/{l}/tasks` with the sender and preview in the body and a `linkedResources` - entry pointing back at the message in Outlook. -- **Scopes:** `Tasks.ReadWrite`, `Mail.Read` -- **Tier:** P1 -- **Notes:** write. The task carries a working link back to the original mail. +| File | Commands | +|---|---| +| [`commands/top-level.md`](commands/top-level.md) | `login`, `logout`, `status`, `claims`, `me`, `version`, `api`, `search` | +| [`commands/config.md`](commands/config.md) | `config path`, `config show`, `config init`, `config set`, `config unset` | +| [`commands/mail.md`](commands/mail.md) | `mail list`, `mail read`, `mail attachments`, `mail send`, `mail reply`, `mail forward`, `mail folders`, `mail mark`, `mail move`, `mail delete`, `mail drafts list`, `mail drafts create`, `mail drafts send`, `mail rules list`, `mail categories` | +| [`commands/mailbox.md`](commands/mailbox.md) | `mailbox settings`, `mailbox oof get`, `mailbox oof set`, `mailbox focused` | +| [`commands/calendar.md`](commands/calendar.md) | `calendar list`, `calendar calendars`, `calendar get`, `calendar create`, `calendar update`, `calendar delete`, `calendar respond`, `calendar availability`, `calendar find-times` | +| [`commands/people.md`](commands/people.md) | `people search`, `people contacts`, `people contact`, `people users`, `people user`, `people photo` | +| [`commands/org.md`](commands/org.md) | `org manager`, `org reports`, `org chain` | +| [`commands/groups.md`](commands/groups.md) | `groups list`, `groups members` | +| [`commands/teams.md`](commands/teams.md) | `teams list`, `teams get`, `teams members`, `teams channels`, `teams channel get`, `teams channel messages`, `teams channel send` | +| [`commands/chats.md`](commands/chats.md) | `chats list`, `chats get`, `chats members`, `chats messages`, `chats send`, `chats dm`, `chats create`, `chats search`, `chats hosted-content` | +| [`commands/presence.md`](commands/presence.md) | `presence get`, `presence set available`, `presence clear` | +| [`commands/meetings.md`](commands/meetings.md) | `meetings list`, `meetings get`, `meetings transcripts`, `meetings transcript`, `meetings insights`, `meetings recordings` | +| [`commands/onedrive.md`](commands/onedrive.md) | `onedrive ls`, `onedrive search`, `onedrive get`, `onedrive download`, `onedrive upload`, `onedrive mkdir`, `onedrive move`, `onedrive rename`, `onedrive delete`, `onedrive share`, `onedrive shared-with-me`, `onedrive recent`, `onedrive link` | +| [`commands/sharepoint.md`](commands/sharepoint.md) | `sharepoint sites`, `sharepoint site`, `sharepoint drives`, `sharepoint ls`, `sharepoint search`, `sharepoint download`, `sharepoint upload`, `sharepoint url`, `sharepoint lists`, `sharepoint items` | +| [`commands/onenote.md`](commands/onenote.md) | `onenote notebooks`, `onenote sections`, `onenote pages`, `onenote read`, `onenote create`, `onenote search` | +| [`commands/planner.md`](commands/planner.md) | `planner plans`, `planner plan`, `planner buckets`, `planner tasks`, `planner task`, `planner create`, `planner update`, `planner complete`, `planner delete` | +| [`commands/todo.md`](commands/todo.md) | `todo lists`, `todo tasks`, `todo task`, `todo create`, `todo update`, `todo complete`, `todo delete`, `todo from-mail` | ## Argument resolution diff --git a/skills/mgraphctl/reference/commands/calendar.md b/skills/mgraphctl/reference/commands/calendar.md new file mode 100644 index 0000000..ed9bb84 --- /dev/null +++ b/skills/mgraphctl/reference/commands/calendar.md @@ -0,0 +1,162 @@ +# `calendar` + +Conventions, argument resolution, paging defaults, exit codes and environment variables +are in [`../commands.md`](../commands.md). + +### `calendar list` + +| Option | Default | Meaning | +|---|---|---| +| `--start DT` (alias `--after`) | now | Window start. | +| `--end DT` (alias `--before`) | `--days` out | Window end. | +| `--days N` | 7 | Days from `--start`, when `--end` is not given. | +| `--calendar NAME\|ID` | the default calendar | Which calendar to read. | +| `--search KW` | — | Client-side match on subject, organizer and attendees. | +| `--limit N` | 50 | Maximum items. | +| `--all` | off | Fetch every page, cap 200. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/calendarView` (or `/me/calendars/{id}/calendarView`) with + `startDateTime`, `endDateTime`, + `$select=id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeeting,isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,webLink`, + `$orderby=start/dateTime`, `$top=50`, and `Prefer: outlook.timezone`. +- **Scopes:** `Calendars.Read` +- **Notes:** `calendarView` expands recurring series into occurrences. Columns: start, end, markers + (`T` Teams meeting, `X` cancelled), subject, organizer, location, id. + +### `calendar calendars` + +| Option | Default | Meaning | +|---|---|---| +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/calendars?$select=id,name,isDefaultCalendar,canEdit,owner,color`. +- **Scopes:** `Calendars.Read` + +### `calendar get ID` + +| Option | Default | Meaning | +|---|---|---| +| `--html` | off | Show the HTML body verbatim. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/events/{id}` with `Prefer: outlook.timezone`, and + `Prefer: outlook.body-content-type="text"` unless `--html`. +- **Scopes:** `Calendars.Read` +- **Notes:** shows every attendee with their response status, and the Teams join URL when there is + one. + +### `calendar create` + +| Option | Default | Meaning | +|---|---|---| +| `--subject TEXT` | — | Event title. Required. | +| `--start DT` | — | Start. Required. | +| `--end DT` | `--start` + `--duration` | End. | +| `--duration D` | `30m` | Length, when `--end` is not given. | +| `--all-day` / `--no-all-day` | off | All-day event. | +| `--attendees ADDR` | — | Required attendee. Repeatable. | +| `--optional ADDR` | — | Optional attendee. Repeatable. | +| `--body TEXT` | — | Event body. | +| `--html` | off | The body is HTML. | +| `--location TEXT` | — | Location display name. | +| `--teams` / `--no-teams` | off | Add a Teams online meeting. | +| `--reminder MIN` | — | Minutes before start. | +| `--show-as STATE` | — | `free`, `tentative`, `busy`, `oof`, `workingElsewhere`. | +| `--category X` | — | Category. Repeatable. | +| `--calendar NAME\|ID` | the default calendar | Which calendar to create in. | +| `--transaction-id ID` | a generated uuid4 | Idempotency key. | +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `POST /me/events` (or `/me/calendars/{id}/events`) with + `{subject, start, end, isAllDay, attendees, body, location, isOnlineMeeting, + onlineMeetingProvider:"teamsForBusiness", reminderMinutesBeforeStart, showAs, categories, + transactionId}` and `Prefer: outlook.timezone`. +- **Scopes:** `Calendars.ReadWrite` +- **Notes:** write. Returns the event, including `onlineMeeting.joinUrl` with `--teams`. An all-day + event with no `--end` ends at midnight the next day. Graph sends the invitations. + +### `calendar update ID` + +Every `calendar create` option except `--calendar` and `--transaction-id`; all optional. + +| Option | Default | Meaning | +|---|---|---| +| `--subject` / `--start` / `--end` / `--duration` | — | Change these fields. | +| `--all-day` / `--no-all-day` | — | Change the all-day flag. | +| `--attendees` / `--optional ADDR` | — | Replace the attendee list. Repeatable. | +| `--body TEXT` / `--html` | — | Replace the body. | +| `--teams` / `--no-teams` | — | Add or remove the Teams online meeting. | +| `--location` / `--reminder` / `--show-as` / `--category` | — | Change these fields. | +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `PATCH /me/events/{id}` with only the fields given, and `Prefer: outlook.timezone`. +- **Scopes:** `Calendars.ReadWrite` +- **Notes:** write. Graph cannot move an event between calendars, so there is no `--calendar`. + Updating a series master updates the whole series. + +### `calendar delete ID` + +| Option | Default | Meaning | +|---|---|---| +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `DELETE /me/events/{id}`. +- **Scopes:** `Calendars.ReadWrite` +- **Notes:** write. When the signed-in user is the organizer, Graph sends cancellations to every + attendee. + +### `calendar respond ID accept|decline|tentative` + +| Option | Default | Meaning | +|---|---|---| +| `--comment TEXT` | — | Comment to send with the response. | +| `--send` / `--no-send` | on | Send the response to the organizer. | +| `--propose-start DT` | — | Propose a new start (decline or tentative only). | +| `--propose-end DT` | — | Propose a new end (decline or tentative only). | +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `POST /me/events/{id}/accept` | `/decline` | `/tentativelyAccept` with + `{comment, sendResponse, proposedNewTime}`. +- **Scopes:** `Calendars.ReadWrite` +- **Notes:** write. JSON `{"status":"accepted"|"declined"|"tentativelyAccepted"}`. + +### `calendar availability` + +| Option | Default | Meaning | +|---|---|---| +| `--users ADDR` | the signed-in user | Mailbox to check. Repeatable. | +| `--start DT` | now, floored to the interval | Window start. | +| `--end DT` | end of today | Window end. | +| `--interval MIN` | 30 | Minutes per slot (5–1440). | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `POST /me/calendar/getSchedule {schedules, startTime, endTime, + availabilityViewInterval}` with `Prefer: outlook.timezone`; with no `--users`, one + `GET /me?$select=mail,userPrincipalName` first. +- **Scopes:** `Calendars.Read` +- **Notes:** text lists each user's busy, tentative, out-of-office and working-elsewhere blocks, + and the free windows computed from `availabilityView`. + +### `calendar find-times` + +| Option | Default | Meaning | +|---|---|---| +| `--attendees ADDR` | — | Attendee. Repeatable. At least one required. | +| `--duration D` | `30m` | Meeting length. | +| `--start DT` | now | Earliest start. | +| `--end DT` | +7d | Latest end. | +| `--max N` | 5 | Maximum candidates. | +| `--domain DOMAIN` | `work` | `work`, `personal` or `unrestricted`. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `POST /me/findMeetingTimes {attendees, timeConstraint:{activityDomain, timeSlots}, + meetingDuration, maxCandidates, returnSuggestionReasons:true}` with `Prefer: outlook.timezone`. +- **Scopes:** `Calendars.Read.Shared` +- **Beyond `default`:** always. +- **Notes:** text shows each suggestion with its confidence and per-attendee availability. When + Graph returns nothing it surfaces `emptySuggestionsReason`. diff --git a/skills/mgraphctl/reference/commands/chats.md b/skills/mgraphctl/reference/commands/chats.md new file mode 100644 index 0000000..40b7c97 --- /dev/null +++ b/skills/mgraphctl/reference/commands/chats.md @@ -0,0 +1,153 @@ +# `chats` + +Conventions, argument resolution, paging defaults, exit codes and environment variables +are in [`../commands.md`](../commands.md). + +### `chats list` + +| Option | Default | Meaning | +|---|---|---| +| `--unread` | off | Only chats with unread messages. | +| `--type TYPE` | — | `oneOnOne`, `group` or `meeting`. | +| `--since DT` | — | Only chats whose last message is newer than this. | +| `--limit N` | 20 | Maximum items. | +| `--all` | off | Fetch every page, cap 200. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/chats?$top=50&$expand=members,lastMessagePreview&$orderby=lastMessagePreview/createdDateTime desc&$select=id,topic,chatType,lastUpdatedDateTime,viewpoint,webUrl`. +- **Scopes:** `Chat.Read` +- **Notes:** unread is computed client-side from `viewpoint.lastMessageReadDateTime` against + `lastMessagePreview.createdDateTime`, and shows only in text mode. A 1:1 chat is named after the + other member; a group chat shows its topic or its first three members. + `--since` adds no query parameter — the listing is already ordered by + `lastMessagePreview/createdDateTime desc`, so paging simply stops at the first chat whose last + message predates it, and the chats past that boundary are dropped. Reaching the boundary is not + a truncation; the 200 cap still is. A chat with no readable preview timestamp says nothing + about where the boundary is, so it neither stops the fetch nor is dropped from it. + With `--since`, text mode gains a `lastMessage` column + showing the timestamp the bound is measured against; the JSON is unchanged either way, since + `lastMessagePreview` is always expanded. + +### `chats get CHAT` + +| Option | Default | Meaning | +|---|---|---| +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /chats/{id}?$expand=members`. +- **Scopes:** `Chat.Read` + +### `chats members CHAT` + +| Option | Default | Meaning | +|---|---|---| +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /chats/{id}/members`. +- **Scopes:** `Chat.Read` + +### `chats messages CHAT` + +| Option | Default | Meaning | +|---|---|---| +| `--after DT` | — | Only messages last touched after this. | +| `--before DT` | — | Only messages last touched before this. | +| `--full` | off | Print whole bodies, not the first 300 characters. | +| `--limit N` | 20 | Maximum items. | +| `--all` | off | Fetch every page, cap 200. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /chats/{id}/messages?$top=50&$orderby=createdDateTime desc`; with a window, + `$orderby=lastModifiedDateTime desc&$filter=lastModifiedDateTime gt {after} and lastModifiedDateTime lt {before}`. +- **Scopes:** `Chat.Read` +- **Notes:** text prints oldest first with Markdown bodies and `[image: hostedContents/]` + markers; JSON keeps Graph's order, newest first. + A window filters and orders on `lastModifiedDateTime`, not `createdDateTime`: Graph supports + `gt`/`lt` only on that property (`createdDateTime` takes `lt` alone), and ignores a `$filter` + whose property `$orderby` does not also name. So the bounds are the *last touched* time — an + edited message sorts and filters by its edit, and the oldest-first text order follows that + same property rather than creation time. Without a window the order stays + `createdDateTime desc`. `--after` later than `--before` is a usage error. + +### `chats send CHAT` + +| Option | Default | Meaning | +|---|---|---| +| `--body TEXT` | — | Message text. | +| `--body-file FILE\|-` | — | File holding the text, or `-` for stdin. | +| `--html` | off | Send the body as HTML. | +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `POST /chats/{id}/messages {body:{contentType:"text"|"html", content}}`. +- **Scopes:** `ChatMessage.Send` (implied by `Chat.ReadWrite`) +- **Notes:** write. + +### `chats dm USER` + +| Option | Default | Meaning | +|---|---|---| +| `--body TEXT` | — | Message text. | +| `--body-file FILE\|-` | — | File holding the text, or `-` for stdin. | +| `--html` | off | Send the body as HTML. | +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /users/{upn}?$select=id,displayName`; then page + `GET /me/chats?$filter=chatType eq 'oneOnOne'&$expand=members&$top=50` (cap 500) looking for a + chat with that user; if there is none, `POST /chats {chatType:"oneOnOne", members:[…]}`; then + `POST /chats/{id}/messages`. +- **Scopes:** `Chat.Read`, `ChatMessage.Send`; creating the chat additionally checks `Chat.Create` + before the `POST`. +- **Beyond `default`:** only when it has to create the chat. +- **Notes:** write. The dry run lists both possible paths, with `{chatId}` standing for the id the + create step would produce. + +### `chats create` + +| Option | Default | Meaning | +|---|---|---| +| `--members UPN` | — | Member to add. Repeatable. At least one required. | +| `--topic TEXT` | — | Group chat topic. | +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `POST /chats {chatType: "oneOnOne" (one member, no topic) | "group", topic, members}`. +- **Scopes:** `Chat.Create` +- **Beyond `default`:** always. +- **Notes:** write. + +### `chats search Q` + +| Option | Default | Meaning | +|---|---|---| +| `--after DT` | — | Only hits after this time. | +| `--before DT` | — | Only hits before this time. | +| `--limit N` | 25 | Maximum items. | +| `--all` | off | Fetch every page, cap 200. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `POST /search/query {requests:[{entityTypes:["chatMessage"], query:{queryString}, from, + size:25}]}`, paging on `moreResultsAvailable`. +- **Scopes:** `Chat.Read`; channel hits also need `ChannelMessage.Read.All` to read further. +- **Notes:** the date window is applied client-side on `createdDateTime`. Its body is the Search + API's `summary` snippet, not the whole message — read the thread with `chats messages` or + `teams channel messages`. + Each hit carries its routing twice: `where` is the text column, `chat:` or + `channel:/`, and `kind` (`chat`, `channel` or `unknown`) with `chatId`, + `teamId` and `channelId` give a JSON consumer the same thing as ids, so a hit can be followed + into a windowed fetch without parsing that string apart. A hit Graph supplied no routing for — + or only half a `channelIdentity` — is `unknown` with an empty `where`, never + `channel:None/None`. + +### `chats hosted-content CHAT MSGID HCID` | `chats hosted-content URL` + +| Option | Default | Meaning | +|---|---|---| +| `--output FILE` | `teams_hosted_.` | Where to write it. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /chats/{c}/messages/{m}/hostedContents/{h}/$value` (streamed). A + `graph.microsoft.com` URL containing `/hostedContents/` — chat or channel — is used verbatim. +- **Scopes:** `Chat.Read`; a channel URL additionally checks `ChannelMessage.Read.All`. +- **Notes:** the extension is sniffed from the magic bytes (png, jpg, gif, webp, pdf, svg, bin). diff --git a/skills/mgraphctl/reference/commands/config.md b/skills/mgraphctl/reference/commands/config.md new file mode 100644 index 0000000..52793aa --- /dev/null +++ b/skills/mgraphctl/reference/commands/config.md @@ -0,0 +1,65 @@ +# `config` + +Conventions, argument resolution, paging defaults, exit codes and environment variables +are in [`../commands.md`](../commands.md). + +The optional TOML file at `~/.mgraphctl/config.toml` (or `--config PATH` / `MGRAPHCTL_CONFIG`). +Nothing in it is secret; the token cache stays in the OS keychain or a separate file. + +### `config path` + +| Option | Default | Meaning | +|---|---|---| +| `--json` | off | `{"path", "exists"}`. | + +- **Graph:** none. +- **Scopes:** none. +- **Notes:** prints the path the other commands read, whether or not it exists. + +### `config show` + +| Option | Default | Meaning | +|---|---|---| +| `--json` | off | `{"path", "settings": {key: value}, "sources": {key: flag|env|file|default}, "unknownKeys"}`. | + +- **Graph:** none. +- **Scopes:** none. +- **Notes:** one row per key with its effective value and where it came from. Keys in the file + that mgraphctl does not know are listed, not rejected. A file that is not valid TOML fails + this and every other command with `error[CONFIG]` (exit 2). + +### `config init` + +| Option | Default | Meaning | +|---|---|---| +| `--force` | off | Overwrite an existing file. | +| `--json` | off | `{"path", "overwritten"}`. | + +- **Graph:** none. +- **Scopes:** none. +- **Notes:** writes a template with every key commented out at its default, mode `0600` in a + `0700` directory. Refuses to overwrite without `--force` (`error[USAGE]`). + +### `config set KEY VALUE` + +| Option | Default | Meaning | +|---|---|---| +| `--json` | off | `{"path", "key", "value"}`. | + +- **Graph:** none. +- **Scopes:** none. +- **Notes:** replaces the key's line in place (uncommenting a template line), or appends it; + every comment survives. Creates the file when there is none. `KEY` must be one of the config + keys; `debug`, `retries`, `timeout_ms` and `retry_base_ms` take non-negative integers, `tz` + an IANA name and `token_store` one of `auto`, `keyring`, `file` — anything else is + `error[USAGE]` and the file is untouched. + +### `config unset KEY` + +| Option | Default | Meaning | +|---|---|---| +| `--json` | off | `{"path", "key", "removed"}` — `removed` is false when the key was not set. | + +- **Graph:** none. +- **Scopes:** none. +- **Notes:** comments the key's line out, so the environment variable or the default applies. diff --git a/skills/mgraphctl/reference/commands/groups.md b/skills/mgraphctl/reference/commands/groups.md new file mode 100644 index 0000000..31f7c43 --- /dev/null +++ b/skills/mgraphctl/reference/commands/groups.md @@ -0,0 +1,30 @@ +# `groups` + +Conventions, argument resolution, paging defaults, exit codes and environment variables +are in [`../commands.md`](../commands.md). + +### `groups list` + +| Option | Default | Meaning | +|---|---|---| +| `--unified` | off | Only Microsoft 365 (unified) groups. | +| `--limit N` | 20 | Maximum items. | +| `--all` | off | Fetch every page, cap 999. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/memberOf/microsoft.graph.group?$select=id,displayName,mail,groupTypes,description&$top=100`; + `--unified` adds `$filter=groupTypes/any(c:c eq 'Unified')&$count=true` and + `ConsistencyLevel: eventual`. +- **Scopes:** `User.Read`; `Group.Read.All` widens what Graph returns. + +### `groups members GROUP` + +| Option | Default | Meaning | +|---|---|---| +| `--limit N` | 20 | Maximum items. | +| `--all` | off | Fetch every page, cap 999. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /groups/{id}/members?$select=id,displayName,userPrincipalName,mail,jobTitle&$top=100`. +- **Scopes:** `Group.Read.All` +- **Notes:** `GROUP` may be a group id or a display name from the groups you belong to. diff --git a/skills/mgraphctl/reference/commands/mail.md b/skills/mgraphctl/reference/commands/mail.md new file mode 100644 index 0000000..4cd1d5a --- /dev/null +++ b/skills/mgraphctl/reference/commands/mail.md @@ -0,0 +1,251 @@ +# `mail` + +Conventions, argument resolution, paging defaults, exit codes and environment variables +are in [`../commands.md`](../commands.md). + +### `mail list` + +| Option | Default | Meaning | +|---|---|---| +| `--folder NAME\|ID\|all` | `inbox` | Folder name, well-known name, id, or `all` for the whole mailbox. | +| `--unread` | off | Only unread messages. | +| `--from ADDR` | — | Sender address. Repeatable. | +| `--to ADDR` | — | Recipient address. Repeatable. | +| `--search KQL` | — | KQL query. | +| `--after DT` | — | Only messages received after this. | +| `--before DT` | — | Only messages received before this. | +| `--select a,b` | — | `$select` override. | +| `--limit N` | 10 | Maximum items. | +| `--all` | off | Fetch every page, cap 500. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/mailFolders/{folder}/messages` (`--folder all` reads `GET /me/messages`). + **Search mode** (any of `--search`/`--from`/`--to`): `$search=` KQL, `$top=25`, no `$orderby`. + **Filter mode**: `$filter=receivedDateTime ge/le …[ and isRead eq false]`, + `$orderby=receivedDateTime desc`, `$top=50`. Both send + `$select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead,hasAttachments,importance,bodyPreview,conversationId,webLink,inferenceClassification` + and `Prefer: outlook.timezone`. +- **Scopes:** `Mail.Read` +- **Notes:** in search mode `--unread` is applied client-side, because KQL has no `isRead` term. + So are `--after`/`--before` when they carry a time of day: KQL's `received` compares on the + calendar date only, so `--after 2026-09-02T14:00` reaches Graph as `received>=2026-09-02` and the + earlier part of that day is dropped here. A date-only bound needs no such pass and gets none. + Both passes run after paging, so a filtered page can be shorter than `--limit` while + `truncated` is still true. + Columns: id, received, flags (`*` unread, `A` attachment, `!` high importance), from, subject. + The default folder is the Inbox; pass `--folder all` to list the whole mailbox. + +### `mail read ID` + +| Option | Default | Meaning | +|---|---|---| +| `--html` | off | Show the HTML body verbatim instead of text. | +| `--full` | off | Do not truncate the body at 4000 characters. | +| `--headers` | off | Also show the internet message headers. | +| `--output FILE` | — | Write the full body to this file. | +| `--save-attachments DIR` | — | Save every file attachment into this directory. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/messages/{id}?$select=…,body,uniqueBody,replyTo[,internetMessageHeaders]` with + `Prefer: outlook.timezone` and `Prefer: outlook.body-content-type="text"` unless `--html`; + `--save-attachments` adds the attachment list plus `GET …/attachments/{aid}/$value` per file. +- **Scopes:** `Mail.Read` +- **Notes:** truncation prints a note on stderr. Item and reference attachments cannot be + downloaded and are skipped with a stderr note. + +### `mail attachments ID` + +| Option | Default | Meaning | +|---|---|---| +| `--download ATTID` | — | Attachment id to download. | +| `--output FILE` | — | Where to write the `--download` file. | +| `--all-attachments` | off | Download every file attachment. | +| `--output-dir DIR` | — | Directory for `--all-attachments`. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/messages/{id}/attachments?$select=id,name,contentType,size,isInline`; + download: `GET …/attachments/{aid}/$value` (streamed). +- **Scopes:** `Mail.Read` +- **Notes:** downloading a single itemAttachment or referenceAttachment fails (exit 1); under + `--all-attachments` they are skipped with a stderr note. + +### `mail send` + +| Option | Default | Meaning | +|---|---|---| +| `--to ADDR` | — | Recipient. Repeatable, comma-separated. At least one required. | +| `--cc ADDR` | — | Copy recipient. Repeatable. | +| `--bcc ADDR` | — | Blind copy. Repeatable. | +| `--subject TEXT` | `(no subject)` | Subject line. | +| `--body TEXT` | — | Message body. | +| `--body-file FILE\|-` | — | Body file, or `-` for stdin. | +| `--html` | off | The body is HTML, not plain text. | +| `--attach FILE` | — | File to attach. Repeatable. | +| `--importance LEVEL` | `normal` | `low`, `normal` or `high`. | +| `--save-to-sent` / `--no-save-to-sent` | on | Keep a copy in Sent Items. | +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** **inline path** when the attachments total 2.5 MiB or less: `POST /me/sendMail`. + **Draft path** otherwise: `POST /me/messages`, then per attachment `POST …/attachments` + (under 3 MiB) or `POST …/attachments/createUploadSession` plus chunks, then + `POST /me/messages/{id}/send`. +- **Scopes:** `Mail.Send`; the draft path additionally checks `Mail.ReadWrite` before its first + request. +- **Beyond `default`:** only on the draft path. +- **Notes:** write. JSON `{"status":"sent"}`, plus `"draftId"` on the draft path. The dry run names + the path it would take. + +### `mail reply ID` + +| Option | Default | Meaning | +|---|---|---| +| `--body TEXT` | — | Message body. | +| `--body-file FILE\|-` | — | Body file, or `-` for stdin. | +| `--html` | off | The body is HTML. | +| `--reply-all` | off | Reply to everyone on the message. | +| `--to ADDR` | — | Extra recipient. Repeatable. | +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `POST /me/messages/{id}/reply` (or `/replyAll`) with `{comment}`, or + `{message:{body:{contentType:"HTML"}}}` with `--html`, plus `message.toRecipients` for `--to`. +- **Scopes:** `Mail.Send` +- **Notes:** write. JSON `{"status":"sent"}`. + +### `mail forward ID` + +| Option | Default | Meaning | +|---|---|---| +| `--to ADDR` | — | Recipient. Repeatable, comma-separated. At least one required. | +| `--body TEXT` | — | Comment to add above the forwarded message. | +| `--html` | off | The body is HTML. | +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `POST /me/messages/{id}/forward {toRecipients, comment}`. +- **Scopes:** `Mail.Send` +- **Notes:** write. + +### `mail folders` + +| Option | Default | Meaning | +|---|---|---| +| `--depth N` | 2 | How many levels of the tree to show. | +| `--hidden` | off | Include hidden folders. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/mailFolders?$top=100&$select=id,displayName,parentFolderId,childFolderCount,unreadItemCount,totalItemCount[&includeHiddenFolders=true]`, + then `…/{id}/childFolders` for each folder with children, down to `--depth`. +- **Scopes:** `Mail.Read` +- **Notes:** text indents each level by two spaces; JSON nests them under `children`. + +### `mail mark ID...` + +| Option | Default | Meaning | +|---|---|---| +| `--read` / `--unread` | — | Mark read or unread. | +| `--flag` / `--unflag` / `--flag-complete` | — | Follow-up flag state. | +| `--category X` | — | Category to set. Repeatable. | +| `--clear-categories` | off | Remove every category. | +| `--importance LEVEL` | — | `low`, `normal` or `high`. | +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `PATCH /me/messages/{id} {isRead, flag:{flagStatus}, categories, importance}`; more than + one id goes through `POST /$batch` in chunks of 20. +- **Scopes:** `Mail.ReadWrite` +- **Beyond `default`:** always. +- **Notes:** write. Takes any number of ids. JSON is the list envelope even for a single id. + +### `mail move ID` + +| Option | Default | Meaning | +|---|---|---| +| `--folder NAME\|ID` | — | Destination folder. Required. | +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `POST /me/messages/{id}/move {destinationId}`. +- **Scopes:** `Mail.ReadWrite` +- **Beyond `default`:** always. +- **Notes:** write. Returns the **new** message: the id changes, so any id captured before the move + is dead. Re-list after moving. + +### `mail delete ID` + +| Option | Default | Meaning | +|---|---|---| +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `DELETE /me/messages/{id}`. +- **Scopes:** `Mail.ReadWrite` +- **Beyond `default`:** always. +- **Notes:** write. A soft delete — Outlook moves the message to Deleted Items. JSON + `{"status":"deleted","id":…}`. + +### `mail drafts list` + +| Option | Default | Meaning | +|---|---|---| +| `--limit N` | 20 | Maximum items. | +| `--all` | off | Fetch every page, cap 500. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/mailFolders/drafts/messages?$select=…&$orderby=lastModifiedDateTime desc`. +- **Scopes:** `Mail.Read` + +### `mail drafts create` + +The `mail send` options, minus `--save-to-sent`. + +| Option | Default | Meaning | +|---|---|---| +| `--to` / `--cc` / `--bcc ADDR` | — | Recipients. Repeatable. | +| `--subject TEXT` | — | Subject line. | +| `--body TEXT` / `--body-file FILE\|-` | — | Body, or a file (`-` for stdin). | +| `--html` | off | The body is HTML. | +| `--attach FILE` | — | File to attach. Repeatable. | +| `--importance LEVEL` | `normal` | `low`, `normal` or `high`. | +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `POST /me/messages {subject, body, toRecipients, …}` plus the same attachment steps as + `mail send`. +- **Scopes:** `Mail.ReadWrite` +- **Beyond `default`:** always. +- **Notes:** write. Returns the draft, including the id `mail drafts send` needs. + +### `mail drafts send ID` + +| Option | Default | Meaning | +|---|---|---| +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `POST /me/messages/{id}/send`. +- **Scopes:** `Mail.ReadWrite`, `Mail.Send` +- **Beyond `default`:** always. +- **Notes:** write. + +### `mail rules list` + +| Option | Default | Meaning | +|---|---|---| +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/mailFolders/inbox/messageRules`. +- **Scopes:** `MailboxSettings.Read` +- **Beyond `default`:** always. +- **Notes:** read-only. Columns: id, sequence, enabled, name, actions summary. + +### `mail categories` + +| Option | Default | Meaning | +|---|---|---| +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/outlook/masterCategories`. +- **Scopes:** `MailboxSettings.Read` +- **Beyond `default`:** always. diff --git a/skills/mgraphctl/reference/commands/mailbox.md b/skills/mgraphctl/reference/commands/mailbox.md new file mode 100644 index 0000000..3cfa55a --- /dev/null +++ b/skills/mgraphctl/reference/commands/mailbox.md @@ -0,0 +1,62 @@ +# `mailbox` + +Conventions, argument resolution, paging defaults, exit codes and environment variables +are in [`../commands.md`](../commands.md). + +### `mailbox settings` + +| Option | Default | Meaning | +|---|---|---| +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/mailboxSettings`. +- **Scopes:** `MailboxSettings.Read` +- **Beyond `default`:** always. +- **Notes:** shows the mailbox time zone, language, working hours and automatic-replies status. + +### `mailbox oof get` + +| Option | Default | Meaning | +|---|---|---| +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/mailboxSettings/automaticRepliesSetting`. +- **Scopes:** `MailboxSettings.Read` +- **Beyond `default`:** always. + +### `mailbox oof set` + +| Option | Default | Meaning | +|---|---|---| +| `--message TEXT` | — | Internal reply text. Required unless `--clear`. | +| `--external-message TEXT` | `--message` | External reply text. | +| `--start DT` | — | Start of the period. | +| `--end DT` | — | End of the period. | +| `--external AUDIENCE` | `all` | Who outside sees a reply: `all`, `contacts`, `none`. | +| `--internal-only` | off | Same as `--external none`. | +| `--clear` | off | Turn automatic replies off. | +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `PATCH /me/mailboxSettings {automaticRepliesSetting:{status, externalAudience, + scheduledStartDateTime, scheduledEndDateTime, internalReplyMessage, externalReplyMessage}}`. +- **Scopes:** `MailboxSettings.ReadWrite` +- **Beyond `default`:** always. +- **Notes:** write. `--start` with `--end` sets status `scheduled`; otherwise `alwaysEnabled`; + `--clear` sets `disabled`. `--external contacts` maps to `contactsOnly`. Messages go out as HTML. + +### `mailbox focused` + +| Option | Default | Meaning | +|---|---|---| +| `--other` | off | Show the Other inbox instead of Focused. | +| `--after DT` | `-30d` | Only messages received after this. | +| `--limit N` | 25 | Maximum items. | +| `--all` | off | Fetch every page, cap 500. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/mailFolders/inbox/messages` with + `$filter=receivedDateTime ge {after} and inferenceClassification eq 'focused'` (or `'other'`), + `$orderby=receivedDateTime desc`, the `mail list` `$select`, and `Prefer: outlook.timezone`. +- **Scopes:** `Mail.Read` +- **Notes:** `receivedDateTime` leads the filter so the `$orderby` stays efficient. diff --git a/skills/mgraphctl/reference/commands/meetings.md b/skills/mgraphctl/reference/commands/meetings.md new file mode 100644 index 0000000..5484434 --- /dev/null +++ b/skills/mgraphctl/reference/commands/meetings.md @@ -0,0 +1,112 @@ +# `meetings` + +Conventions, argument resolution, paging defaults, exit codes and environment variables +are in [`../commands.md`](../commands.md). + +All `meetings` verbs except `list` select the meeting three ways: a positional online-meeting id, +`--join-url URL`, or `--event EVENT_ID`. Give exactly one. + +### `meetings list` + +| Option | Default | Meaning | +|---|---|---| +| `--start DT` | today − 7 days | Start of the window. | +| `--end DT` | end of today | End of the window. | +| `--subject KW` | — | Case-insensitive subject substring. | +| `--resolve` | off | Resolve each event to its online-meeting id. | +| `--with-transcripts` | off | Also list transcript ids (implies `--resolve`). | +| `--limit N` | 50 | Maximum items. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/calendarView?…&$select=id,subject,start,end,organizer,isOnlineMeeting,onlineMeeting&$top=50` + with `Prefer: outlook.timezone`, keeping events that have a join URL; `--resolve` adds a + `POST /$batch` of `GET /me/onlineMeetings?$filter=JoinWebUrl eq '{url}'`; + `--with-transcripts` adds `GET /me/onlineMeetings/{id}/transcripts` per resolved meeting. +- **Scopes:** `Calendars.Read`; `--resolve` additionally checks `OnlineMeetings.Read`, and + `--with-transcripts` `OnlineMeetingTranscript.Read.All`. +- **Notes:** columns: start, subject, event id, meeting id (when resolved), transcript ids. + +### `meetings get [MEETING]` + +| Option | Default | Meaning | +|---|---|---| +| `--join-url URL` | — | Select the meeting by its join URL. | +| `--event ID` | — | Select the meeting from a calendar event id. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/onlineMeetings/{id}`, or + `GET /me/onlineMeetings?$filter=JoinWebUrl eq '{url}'`. +- **Scopes:** `OnlineMeetings.Read` + +### `meetings transcripts [MEETING]` + +| Option | Default | Meaning | +|---|---|---| +| `--join-url URL` | — | Select the meeting by its join URL. | +| `--event ID` | — | Select the meeting from a calendar event id. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/onlineMeetings/{id}/transcripts`. +- **Scopes:** `OnlineMeetingTranscript.Read.All` + +### `meetings transcript [MEETING] TRANSCRIPT_ID` + +| Option | Default | Meaning | +|---|---|---| +| `--join-url URL` | — | Select the meeting by its join URL. | +| `--event ID` | — | Select the meeting from a calendar event id. | +| `--format FMT` | `text` | `text` or `vtt`. | +| `--speakers` | off | Merge each speaker's consecutive cues into one turn. | +| `--output FILE` | — | Write to this file instead of stdout. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/onlineMeetings/{m}/transcripts/{t}/content?$format=text/vtt` (streamed). A 403 + `SpeakerAttributionNotAllowed` is retried with + `Accept: application/vnd.microsoft.graph.transcript+text`. With `--json`, one extra + `GET /me/onlineMeetings/{m}/transcripts` supplies `createdDateTime`. +- **Scopes:** `OnlineMeetingTranscript.Read.All` +- **Notes:** with `--join-url` or `--event` the single positional argument is the transcript id; + otherwise give the meeting id and then the transcript id. `text` converts the VTT locally into + `[HH:MM:SS] Speaker: line`, one line per cue. + JSON is `{"meetingId","transcriptId","createdDateTime","format","content"}`. + `format` is **sniffed from the body**, not taken from `--format`: the speaker-attribution + fallback answers in plain text even to a vtt request, and some meeting types answer in vtt to a + text one, so the field says what the content actually is. `--output` reports the same sniffed + value. `createdDateTime` costs one extra listing request, so only `--json` pays it, and a failed + lookup leaves the field `null` rather than failing the command — the content is already in hand. + `--speakers` renders `**Speaker:** text` turns, merging a speaker's consecutive cues into one + and separating turns with a blank line, which is what makes a transcript readable; a cue with no + `` tag continues the turn it falls inside. It respects `--output`, and combined with + `--format vtt` is a usage error rather than a silent override. + +### `meetings insights [MEETING]` + +| Option | Default | Meaning | +|---|---|---| +| `--join-url URL` | — | Select the meeting by its join URL. | +| `--event ID` | — | Select the meeting from a calendar event id. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /copilot/users/{oid}/onlineMeetings/{m}/aiInsights` on `/v1.0`, falling back to + the same path on `/beta` on a 404; then `GET …/aiInsights/{id}` per item. +- **Scopes:** `OnlineMeetingAiInsight.Read.All` +- **Notes:** needs a Microsoft 365 Copilot licence. A 403 prints + `AI insights require a Microsoft 365 Copilot license…` and **exits 0**; an empty or missing recap + is likewise a soft message with exit 0 and a `note` in the JSON. Insights can take up to a few + hours after a meeting ends to appear. + +### `meetings recordings [MEETING]` + +| Option | Default | Meaning | +|---|---|---| +| `--join-url URL` | — | Select the meeting by its join URL. | +| `--event ID` | — | Select the meeting from a calendar event id. | +| `--download RID` | — | Recording id to download. | +| `--output FILE` | — | File to write the recording to. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/onlineMeetings/{id}/recordings`; `--download`: + `GET …/recordings/{rid}/content` (streamed). +- **Scopes:** `OnlineMeetingRecording.Read.All` (*on-demand*) +- **Beyond `default`:** always. +- **Notes:** the missing-scope hint names `login --scope OnlineMeetingRecording.Read.All`. diff --git a/skills/mgraphctl/reference/commands/onedrive.md b/skills/mgraphctl/reference/commands/onedrive.md new file mode 100644 index 0000000..67ef80f --- /dev/null +++ b/skills/mgraphctl/reference/commands/onedrive.md @@ -0,0 +1,179 @@ +# `onedrive` + +Conventions, argument resolution, paging defaults, exit codes and environment variables +are in [`../commands.md`](../commands.md). + +`ls`, `search`, `get`, `download`, `upload`, `mkdir`, `move`, `rename`, `delete` and `share` accept +`--drive DRIVE_ID` to work against another drive; the base is `/me/drive`, or `/drives/{id}` with +`--drive`. `recent`, `shared-with-me` and `link` are always about the signed-in user's own drive. + +A file or folder is named either by id (`id:ID`, or a bare id-shaped string) or by path +(`/Reports/2026/plan.xlsx`). + +### `onedrive ls [PATH]` + +| Option | Default | Meaning | +|---|---|---| +| `--drive ID` | your drive | Target another drive. | +| `--limit N` | 50 | Maximum items. | +| `--all` | off | Fetch every page, cap 1000. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET {base}/root/children`, or `GET {base}/root:/{path}:/children`, with + `$top=200&$select=id,name,size,lastModifiedDateTime,file,folder,webUrl,parentReference&$orderby=name`. +- **Scopes:** `Files.Read` +- **Notes:** columns: type (`d`/`f`), id, size, modified, name. + +### `onedrive search Q` + +| Option | Default | Meaning | +|---|---|---| +| `--drive ID` | your drive | Target another drive. | +| `--shared` | off | Search everything shared with you as well. | +| `--limit N` | 50 | Maximum items. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET {base}/root/search(q='{q}')`, or `GET {base}/search(q='{q}')` with `--shared`, + which also returns remote items. +- **Scopes:** `Files.Read` + +### `onedrive get ID|PATH` + +| Option | Default | Meaning | +|---|---|---| +| `--drive ID` | your drive | Target another drive. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET {base}/items/{id}`, or `GET {base}/root:/{path}`. +- **Scopes:** `Files.Read` + +### `onedrive download ID|PATH` + +| Option | Default | Meaning | +|---|---|---| +| `--drive ID` | your drive | Target another drive. | +| `--output FILE` | the item name | Destination file. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET {base}/items/{id}/content` or `GET {base}/root:/{path}:/content`; the 302 is + followed without the bearer token. +- **Scopes:** `Files.Read` +- **Notes:** writes `.part` and renames it, creating parent directories and overwriting an + existing file. Prints `Downloaded () to `. + +### `onedrive upload FILE` + +| Option | Default | Meaning | +|---|---|---| +| `--drive ID` | your drive | Target another drive. | +| `--dest PATH` | `/` | Destination path; a trailing `/` means a folder. | +| `--conflict MODE` | `replace` | `rename`, `replace` or `fail`. | +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** under 4 MiB, `PUT {base}/root:/{path}:/content?@microsoft.graph.conflictBehavior=…`; + above it, `POST {base}/root:/{path}:/createUploadSession` followed by 10 MiB chunks. +- **Scopes:** `Files.ReadWrite` +- **Notes:** write. The dry run shows the file as `{"$file": …, "bytes": N, "contentType": …}`. + +### `onedrive mkdir PATH` + +| Option | Default | Meaning | +|---|---|---| +| `--drive ID` | your drive | Target another drive. | +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `POST {base}/root:/{parent}:/children {name, folder:{}, + "@microsoft.graph.conflictBehavior":"fail"}`. +- **Scopes:** `Files.ReadWrite` +- **Notes:** write. Fails rather than silently reusing an existing folder. + +### `onedrive move ID|PATH` + +| Option | Default | Meaning | +|---|---|---| +| `--to FOLDER\|id:ID` | — | Destination folder. Required. | +| `--drive ID` | your drive | Target another drive. | +| `--name NAME` | — | Rename while moving. | +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `PATCH {base}/items/{id} {parentReference:{id}, name?}`, after a `GET` to resolve the + destination folder. +- **Scopes:** `Files.ReadWrite` +- **Notes:** write. + +### `onedrive rename ID|PATH NAME` + +| Option | Default | Meaning | +|---|---|---| +| `--drive ID` | your drive | Target another drive. | +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `PATCH {base}/items/{id} {name}`. +- **Scopes:** `Files.ReadWrite` +- **Notes:** write. + +### `onedrive delete ID|PATH` + +| Option | Default | Meaning | +|---|---|---| +| `--drive ID` | your drive | Target another drive. | +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `DELETE {base}/items/{id}`. +- **Scopes:** `Files.ReadWrite` +- **Notes:** write. Moves the item to the recycle bin. + +### `onedrive share ID|PATH` + +| Option | Default | Meaning | +|---|---|---| +| `--drive ID` | your drive | Target another drive. | +| `--type KIND` | `view` | `view` or `edit`. | +| `--scope WHO` | `organization` | `organization` or `anonymous`. | +| `--expires DT` | — | When the link stops working. | +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `POST {base}/items/{id}/createLink {type, scope, expirationDateTime}`. +- **Scopes:** `Files.ReadWrite` +- **Notes:** write. Prints `link.webUrl`. Anonymous links are blocked by policy in many tenants — + a 403 there is a tenant setting, not a missing scope. + +### `onedrive shared-with-me` + +| Option | Default | Meaning | +|---|---|---| +| `--limit N` | 50 | Maximum items. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/drive/sharedWithMe`. +- **Scopes:** `Files.Read.All` or `Sites.Read.All` +- **Notes:** Microsoft is retiring this endpoint; a stderr note says so. Each item carries a + `remoteItem` whose `driveId` and `id` are shown, and which `onedrive get --drive` can open. + +### `onedrive recent` + +| Option | Default | Meaning | +|---|---|---| +| `--limit N` | 20 | Maximum items. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/drive/recent`. +- **Scopes:** `Files.Read` + +### `onedrive link URL` + +| Option | Default | Meaning | +|---|---|---| +| `--download` | off | Download the item's content. | +| `--output FILE` | the item name | Destination file for `--download`. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /shares/{share_id(url)}/driveItem`; `--download` adds `…/driveItem/content`. +- **Scopes:** `Files.Read` (plus whatever access the link itself grants) +- **Notes:** works for OneDrive and SharePoint sharing links alike. diff --git a/skills/mgraphctl/reference/commands/onenote.md b/skills/mgraphctl/reference/commands/onenote.md new file mode 100644 index 0000000..e5731cb --- /dev/null +++ b/skills/mgraphctl/reference/commands/onenote.md @@ -0,0 +1,79 @@ +# `onenote` + +Conventions, argument resolution, paging defaults, exit codes and environment variables +are in [`../commands.md`](../commands.md). + +### `onenote notebooks` + +| Option | Default | Meaning | +|---|---|---| +| `--limit N` | 50 | Maximum items. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/onenote/notebooks?$top=100&$select=id,displayName,lastModifiedDateTime,links`. +- **Scopes:** `Notes.Read` + +### `onenote sections [NOTEBOOK]` + +| Option | Default | Meaning | +|---|---|---| +| `--limit N` | 50 | Maximum items. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/onenote/notebooks/{id}/sections`, or `GET /me/onenote/sections` across every + notebook, with `$top=100&$select=id,displayName,lastModifiedDateTime,parentNotebook`. +- **Scopes:** `Notes.Read` + +### `onenote pages SECTION` + +| Option | Default | Meaning | +|---|---|---| +| `--limit N` | 50 | Maximum items. | +| `--all` | off | Fetch every page, cap 500. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/onenote/sections/{id}/pages?$top=100&$select=id,title,lastModifiedDateTime,links&$orderby=lastModifiedDateTime desc`. +- **Scopes:** `Notes.Read` + +### `onenote read PAGE` + +| Option | Default | Meaning | +|---|---|---| +| `--html` | off | Print the page's raw HTML instead of Markdown. | +| `--output FILE` | — | Write to this file instead of stdout. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/onenote/pages/{id}?$select=id,title,lastModifiedDateTime,links` plus + `GET /me/onenote/pages/{id}/content?includeIDs=true`. +- **Scopes:** `Notes.Read` +- **Notes:** JSON carries `{"id","title","html","markdown"}`. + +### `onenote create` + +| Option | Default | Meaning | +|---|---|---| +| `--section SECTION` | — | Section name or id. Required. | +| `--title TEXT` | — | Page title. Required. | +| `--body TEXT` | — | Page body text. | +| `--body-file FILE\|-` | — | File holding the body, or `-` for stdin. | +| `--html` | off | Treat the body as raw HTML, unescaped. | +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `POST /me/onenote/sections/{id}/pages` with `Content-Type: text/html` and a minimal + HTML document carrying the title and body. +- **Scopes:** `Notes.ReadWrite` +- **Notes:** write. The title and body are HTML-escaped unless `--html` is given. + +### `onenote search Q` + +| Option | Default | Meaning | +|---|---|---| +| `--limit N` | 50 | Maximum items. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/onenote/pages?$search={Q}&$top=100&$select=id,title,createdDateTime,parentSection`. +- **Scopes:** `Notes.Read` +- **Notes:** Graph documents `$search` here for consumer notebooks only. On a work account it may + return 400 or 501; that error is passed through with the hint to use + `search Q --type driveItem` instead. diff --git a/skills/mgraphctl/reference/commands/org.md b/skills/mgraphctl/reference/commands/org.md new file mode 100644 index 0000000..ff9e344 --- /dev/null +++ b/skills/mgraphctl/reference/commands/org.md @@ -0,0 +1,42 @@ +# `org` + +Conventions, argument resolution, paging defaults, exit codes and environment variables +are in [`../commands.md`](../commands.md). + +### `org manager [UPN]` + +| Option | Default | Meaning | +|---|---|---| +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/manager` or `GET /users/{upn}/manager` with + `$select=id,displayName,userPrincipalName,mail,jobTitle,department`. +- **Scopes:** `User.Read`; another user additionally checks `User.Read.All` (*on-demand*). +- **Beyond `default`:** only for another user. +- **Notes:** no manager on record → `No manager found`, exit 4. + +### `org reports [UPN]` + +| Option | Default | Meaning | +|---|---|---| +| `--limit N` | 20 | Maximum items. | +| `--all` | off | Fetch every page, cap 500. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/directReports` or `GET /users/{upn}/directReports` with the same `$select`. +- **Scopes:** `User.Read`; another user additionally checks `User.Read.All` (*on-demand*). +- **Beyond `default`:** only for another user. + +### `org chain [UPN]` + +| Option | Default | Meaning | +|---|---|---| +| `--max N` | 10 | Maximum levels to climb. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me?$expand=manager($levels=max;$select=id,displayName,userPrincipalName,jobTitle)&$count=true` + with `ConsistencyLevel: eventual`; on 400 or 403 it falls back to walking + `GET /users/{id}/manager` one level at a time, up to `--max`. +- **Scopes:** `User.Read`; the iterative fallback additionally checks `User.Read.All` (*on-demand*). +- **Notes:** text prints one line per level, from the user upward; `--json` prints the same order + in the list envelope `{"items": [...], "count": N, "truncated": false}`. diff --git a/skills/mgraphctl/reference/commands/people.md b/skills/mgraphctl/reference/commands/people.md new file mode 100644 index 0000000..7979194 --- /dev/null +++ b/skills/mgraphctl/reference/commands/people.md @@ -0,0 +1,79 @@ +# `people` + +Conventions, argument resolution, paging defaults, exit codes and environment variables +are in [`../commands.md`](../commands.md). + +### `people search Q` + +| Option | Default | Meaning | +|---|---|---| +| `--limit N` | 20 | Maximum items. | +| `--all` | off | Fetch every page, cap 250. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/people?$search="Q"&$top=50&$select=id,displayName,scoredEmailAddresses,jobTitle,department,companyName,personType,userPrincipalName`. +- **Scopes:** `People.Read` +- **Notes:** `/me/people` ranks by relevance to the signed-in user and is in maintenance mode; for a + wider sweep use `search Q --type person` or `people users Q`. + +### `people contacts` + +| Option | Default | Meaning | +|---|---|---| +| `--search Q` | — | Filter by name or email. | +| `--limit N` | 20 | Maximum items. | +| `--all` | off | Fetch every page, cap 250. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/contacts?$top=50&$select=id,displayName,emailAddresses,mobilePhone,businessPhones,jobTitle,companyName[&$search="Q"]`. +- **Scopes:** `Contacts.Read` +- **Notes:** when Graph rejects `$search` with a 400, up to 250 contacts are fetched and matched + client-side instead. + +### `people contact ID` + +| Option | Default | Meaning | +|---|---|---| +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/contacts/{id}`. +- **Scopes:** `Contacts.Read` + +### `people users Q` + +| Option | Default | Meaning | +|---|---|---| +| `--limit N` | 20 | Maximum items. | +| `--all` | off | Fetch every page, cap 999. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /users?$search="displayName:Q" OR "mail:Q"&$count=true&$top=100&$select=id,displayName,userPrincipalName,mail,jobTitle,department,officeLocation` + with `ConsistencyLevel: eventual`. +- **Scopes:** `User.ReadBasic.All` +- **Beyond `default`:** always. +- **Notes:** a directory search. `$search` matches whole tokens, not substrings — "ann" will not + find "Anna". + +### `people user UPN|ID` + +| Option | Default | Meaning | +|---|---|---| +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /users/{x}?$select=id,displayName,userPrincipalName,mail,jobTitle,department,officeLocation,businessPhones,mobilePhone` + (or `GET /me` for `me`). +- **Scopes:** `User.Read`; resolving a bare display name additionally checks `User.ReadBasic.All`. +- **Notes:** accepts a UPN, an object id, `me`, or a display name. + +### `people photo [UPN]` + +| Option | Default | Meaning | +|---|---|---| +| `--output FILE` | `.jpg` | Destination file. | +| `--size WxH` | `96x96` | Photo size. Applies to other users only. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/photo/$value`, or `GET /users/{upn}/photos/{size}/$value` (streamed). +- **Scopes:** `User.Read` for yourself; `User.ReadBasic.All` is checked before reading anyone else. +- **Beyond `default`:** only for another user. +- **Notes:** a mailbox with no photo returns 404 → exit 4. diff --git a/skills/mgraphctl/reference/commands/planner.md b/skills/mgraphctl/reference/commands/planner.md new file mode 100644 index 0000000..53d10d4 --- /dev/null +++ b/skills/mgraphctl/reference/commands/planner.md @@ -0,0 +1,131 @@ +# `planner` + +Conventions, argument resolution, paging defaults, exit codes and environment variables +are in [`../commands.md`](../commands.md). + +Planner endpoints accept no OData parameters, so `--limit` slices client-side after the whole +collection has been fetched. + +### `planner plans` + +| Option | Default | Meaning | +|---|---|---| +| `--limit N` | 50 | Maximum items. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/planner/plans`, unioned with the plans of every Microsoft 365 group you belong + to — `GET /me/memberOf/microsoft.graph.group?$filter=groupTypes/any(c:c eq 'Unified')&$count=true` + with `ConsistencyLevel: eventual`, then a `POST /$batch` of `GET /groups/{id}/planner/plans` — + deduplicated by id. +- **Scopes:** `Tasks.ReadWrite`, `Group.Read.All` +- **Notes:** text shows the owning group's name next to each plan. + +### `planner plan PLAN` + +| Option | Default | Meaning | +|---|---|---| +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /planner/plans/{id}` plus `GET /planner/plans/{id}/details`. +- **Scopes:** `Tasks.ReadWrite` +- **Notes:** JSON is the plan with a `details` member. + +### `planner buckets PLAN` + +| Option | Default | Meaning | +|---|---|---| +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /planner/plans/{id}/buckets`. +- **Scopes:** `Tasks.ReadWrite` + +### `planner tasks [PLAN]` + +| Option | Default | Meaning | +|---|---|---| +| `--my` | off | Your tasks across every plan, instead of one plan's tasks. | +| `--bucket NAME\|ID` | — | Only tasks in this bucket. | +| `--include-completed` | off | Also show tasks at 100 %. | +| `--limit N` | 50 | Maximum items. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /planner/plans/{id}/tasks`, or `GET /me/planner/tasks` with `--my`; `--my` adds a + `POST /$batch` of `GET /planner/plans/{planId}` (up to 20 distinct plans) for plan titles, and + bucket names are fetched for the plans involved. +- **Scopes:** `Tasks.ReadWrite` +- **Notes:** completed tasks are hidden unless `--include-completed`. Columns: id, percent, + priority, due, bucket, plan (with `--my`), title. + +### `planner task ID` + +| Option | Default | Meaning | +|---|---|---| +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /planner/tasks/{id}` plus `GET /planner/tasks/{id}/details`. +- **Scopes:** `Tasks.ReadWrite` +- **Notes:** an error fetching the details is swallowed; the task still prints. + +### `planner create` + +| Option | Default | Meaning | +|---|---|---| +| `--plan PLAN` | — | Plan name or id. Required. | +| `--title TEXT` | — | Task title. Required. | +| `--bucket NAME\|ID` | — | Bucket to file it under. | +| `--due DATE` | — | Due date. | +| `--assign UPN` | — | Assignee. Repeatable. | +| `--priority 0-10` | — | Planner priority. | +| `--description TEXT` | — | Task description. | +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /users/{upn}?$select=id` per assignee, then + `POST /planner/tasks {planId, bucketId, title, dueDateTime, priority, assignments}`; + `--description` adds `GET …/details` for the etag and `PATCH …/details` with `If-Match`. +- **Scopes:** `Tasks.ReadWrite` +- **Notes:** write. Assignees are given as UPNs, not object ids. + +### `planner update ID` + +| Option | Default | Meaning | +|---|---|---| +| `--title TEXT` | — | New title. | +| `--due DATE` | — | New due date. | +| `--percent N` | — | Completion: 0, 50 or 100. | +| `--bucket NAME\|ID` | — | Move to another bucket. | +| `--priority 0-10` | — | New priority. | +| `--assign UPN` | — | Add an assignee. Repeatable. | +| `--unassign UPN` | — | Remove an assignee. Repeatable. | +| `--description TEXT` | — | New description. | +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /planner/tasks/{id}` for the etag, then `PATCH /planner/tasks/{id}` with + `If-Match` and `Prefer: return=representation`; a 412 re-reads the etag once and retries. +- **Scopes:** `Tasks.ReadWrite` +- **Notes:** write. Planner tracks completion as a percentage; Planner's own UI only ever sets + 0, 50 or 100. + +### `planner complete ID` + +| Option | Default | Meaning | +|---|---|---| +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** the `planner update` request with `percentComplete: 100`. +- **Scopes:** `Tasks.ReadWrite` +- **Notes:** write. + +### `planner delete ID` + +| Option | Default | Meaning | +|---|---|---| +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /planner/tasks/{id}` for the etag, then `DELETE /planner/tasks/{id}` with + `If-Match`. +- **Scopes:** `Tasks.ReadWrite` +- **Notes:** write. Planner has no recycle bin — a deleted task is gone. diff --git a/skills/mgraphctl/reference/commands/presence.md b/skills/mgraphctl/reference/commands/presence.md new file mode 100644 index 0000000..97e6e2d --- /dev/null +++ b/skills/mgraphctl/reference/commands/presence.md @@ -0,0 +1,43 @@ +# `presence` + +Conventions, argument resolution, paging defaults, exit codes and environment variables +are in [`../commands.md`](../commands.md). + +### `presence get [USER...]` + +| Option | Default | Meaning | +|---|---|---| +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/presence`; with users, `POST /communications/getPresencesByUserId {ids}` after + resolving each UPN. +- **Scopes:** `Presence.Read`; reading anyone else additionally checks `Presence.Read.All` + (*on-demand*). +- **Beyond `default`:** always. + +### `presence set available|busy|dnd|brb|away|offline` + +| Option | Default | Meaning | +|---|---|---| +| `--expiration D` | `1h` | How long it lasts (`30m`, `2h`, `PT1H`). | +| `--message TEXT` | — | Status message to show alongside it. | +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `POST /users/{my-oid}/presence/setUserPreferredPresence {availability, activity, + expirationDuration}`; `--message` adds `POST …/presence/setStatusMessage`. +- **Scopes:** `Presence.ReadWrite` +- **Beyond `default`:** always. +- **Notes:** write. The object id comes from the token's `oid` claim, so there is no `/me` call. + +### `presence clear` + +| Option | Default | Meaning | +|---|---|---| +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `POST /users/{my-oid}/presence/clearUserPreferredPresence`. +- **Scopes:** `Presence.ReadWrite` +- **Beyond `default`:** always. +- **Notes:** write. Hands presence back to Teams. diff --git a/skills/mgraphctl/reference/commands/sharepoint.md b/skills/mgraphctl/reference/commands/sharepoint.md new file mode 100644 index 0000000..24b2f1c --- /dev/null +++ b/skills/mgraphctl/reference/commands/sharepoint.md @@ -0,0 +1,135 @@ +# `sharepoint` + +Conventions, argument resolution, paging defaults, exit codes and environment variables +are in [`../commands.md`](../commands.md). + +`SITE` accepts a site URL, a `host:/sites/name` reference, a composite site id, or a site name to +search for. + +### `sharepoint sites` + +| Option | Default | Meaning | +|---|---|---| +| `--search Q` | — | Search text; `*` matches everything. | +| `--limit N` | 20 | Maximum items. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/followedSites?$select=id,displayName,webUrl`; with `--search`, or when the + followed list is empty, `GET /sites?search={Q or *}&$top={limit}`. +- **Scopes:** `Sites.Read.All` + +### `sharepoint site REF` + +| Option | Default | Meaning | +|---|---|---| +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /sites/{ref}?$select=id,displayName,name,webUrl,description`. +- **Scopes:** `Sites.Read.All` +- **Notes:** use this to turn a URL a colleague sent into the site id the other verbs take. + +### `sharepoint drives SITE` + +| Option | Default | Meaning | +|---|---|---| +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /sites/{id}/drives?$select=id,name,webUrl,driveType`. +- **Scopes:** `Sites.Read.All` + +### `sharepoint ls SITE [PATH]` + +| Option | Default | Meaning | +|---|---|---| +| `--drive NAME\|ID` | the site's default drive | Which document library. | +| `--limit N` | 50 | Maximum items. | +| `--all` | off | Fetch every page, cap 1000. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** as `onedrive ls`, with the base `/sites/{id}/drive` or `/drives/{d}`. +- **Scopes:** `Sites.Read.All` +- **Notes:** item ids are printed in full, so they can be fed straight to `sharepoint download`. + +### `sharepoint search Q` + +| Option | Default | Meaning | +|---|---|---| +| `--site SITE` | — | Search within one site's drive instead of everywhere. | +| `--limit N` | 25 | Maximum items. | +| `--all` | off | Fetch every page, cap 200. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** with `--site`, `GET /sites/{id}/drive/root/search(q='{q}')`; without it, + `POST /search/query` with `entityTypes:["driveItem"]` and size 25. +- **Scopes:** `Sites.Read.All` + +### `sharepoint download SITE ITEM|PATH` + +| Option | Default | Meaning | +|---|---|---| +| `--output FILE` | the item name | Destination file. | +| `--drive NAME\|ID` | the site's default drive | Which document library. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /sites/{id}/drive/items/{item}/content`, or + `GET /drives/{d}/root:/{path}:/content`. +- **Scopes:** `Sites.Read.All` +- **Notes:** reads the **site** drive, not the signed-in user's OneDrive. + +### `sharepoint upload SITE FILE` + +| Option | Default | Meaning | +|---|---|---| +| `--dest PATH` | `/` | Destination path in the library. | +| `--drive NAME\|ID` | the site's default drive | Which document library. | +| `--conflict MODE` | `replace` | `rename`, `replace` or `fail`. | +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** as `onedrive upload`, with the site drive as the base. +- **Scopes:** `Sites.ReadWrite.All` +- **Notes:** write. + +### `sharepoint url URL` + +| Option | Default | Meaning | +|---|---|---| +| `--output FILE` | the file's own name | Destination file. | +| `--info` | off | Resolve and print, without downloading. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** first `GET /shares/{share_id(url)}/driveItem`; on a 4xx it parses the URL's host and + `sites`/`teams`/`personal` segment, resolves the site and its drives, picks the drive whose + `webUrl` is the deepest prefix of the file path, and reads + `GET /drives/{d}/root:/{rel}:/content`, with `GET /sites/{id}/drive/root:/{rel}:/content` as a + last resort. +- **Scopes:** `Sites.Read.All` +- **Notes:** the fastest way to open a link someone pasted into chat or mail. `--info` prints the + resolution — site id, drive id, path, item — and downloads nothing; JSON includes `resolution`. + A `/personal/` (OneDrive) URL needs the owner to have shared the file with you. + +### `sharepoint lists SITE` + +| Option | Default | Meaning | +|---|---|---| +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /sites/{id}/lists?$select=id,displayName,webUrl,list`. +- **Scopes:** `Sites.Read.All` +- **Notes:** system lists are hidden. + +### `sharepoint items SITE LIST` + +| Option | Default | Meaning | +|---|---|---| +| `--fields a,b` | every field | Which columns to fetch. | +| `--filter ODATA` | — | OData filter over `fields/*`. | +| `--limit N` | 50 | Maximum items. | +| `--all` | off | Fetch every page, cap 500. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /sites/{id}/lists/{l}/items?$expand=fields($select=…)&$top=200[&$filter=…]`, with + `Prefer: HonorNonIndexedQueriesWarningMayFailRandomly` when `--filter` is given. +- **Scopes:** `Sites.Read.All` +- **Notes:** text shows the first eight fields as columns; use `--json` to see them all. A filter on + an unindexed column may fail intermittently — that is the SharePoint list threshold, not a bug. diff --git a/skills/mgraphctl/reference/commands/teams.md b/skills/mgraphctl/reference/commands/teams.md new file mode 100644 index 0000000..ca4b39f --- /dev/null +++ b/skills/mgraphctl/reference/commands/teams.md @@ -0,0 +1,104 @@ +# `teams` + +Conventions, argument resolution, paging defaults, exit codes and environment variables +are in [`../commands.md`](../commands.md). + +### `teams list` + +| Option | Default | Meaning | +|---|---|---| +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/joinedTeams` (Graph accepts no OData parameters on this path). +- **Scopes:** `Team.ReadBasic.All` or `Group.Read.All` +- **Notes:** returns every team, unpaged; there is no `--limit`. + +### `teams get TEAM` + +| Option | Default | Meaning | +|---|---|---| +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /teams/{t}`. +- **Scopes:** `Team.ReadBasic.All` or `Group.Read.All` +- **Notes:** `TEAM` may be a team id (GUID) or a display name from `teams list`. + +### `teams members TEAM` + +| Option | Default | Meaning | +|---|---|---| +| `--limit N` | 100 | Maximum items. | +| `--all` | off | Fetch every page, cap 999. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /teams/{t}/members`. +- **Scopes:** `TeamMember.Read.All` +- **Beyond `default`:** always. + +### `teams channels TEAM` + +| Option | Default | Meaning | +|---|---|---| +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /teams/{t}/channels?$select=id,displayName,description,membershipType`. +- **Scopes:** `Channel.ReadBasic.All` or `Group.Read.All` + +### `teams channel get TEAM CHANNEL` + +| Option | Default | Meaning | +|---|---|---| +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /teams/{t}/channels/{c}`. +- **Scopes:** `Channel.ReadBasic.All` or `Group.Read.All` +- **Notes:** `CHANNEL` may be a channel id (starts with `19:`) or a display name. + +### `teams channel messages TEAM CHANNEL` + +| Option | Default | Meaning | +|---|---|---| +| `--full` | off | Print whole bodies, not the first 300 characters. | +| `--with-replies` | off | Expand each message's replies. | +| `--replies MSGID` | — | List the replies to one message instead. | +| `--after DT` | — | Only messages whose reply chain was touched after this. | +| `--before DT` | — | Only messages whose reply chain was touched before this. | +| `--limit N` | 20 | Maximum items. | +| `--all` | off | Fetch every page, cap 200. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /teams/{t}/channels/{c}/messages?$top=50[&$expand=replies]`; + `--replies`: `GET …/messages/{m}/replies?$top=50`, paged and bounded by `--limit`. +- **Scopes:** `ChannelMessage.Read.All` +- **Notes:** deleted messages and system placeholders are dropped. Text mode prints oldest first + with HTML bodies converted to Markdown; JSON keeps Graph's order, newest first. + `--after`/`--before` add no query parameter: Graph documents `$top` and `$expand` as the only + ones this endpoint supports, in v1.0 and beta alike, so a `$filter` there would be rejected or — + worse — ignored, and an ignored one would return an unfiltered page dressed up as a window. The + window is applied client-side instead, on the last-modified time of the whole reply chain, which + is the order Graph returns messages in. Because that order is newest first, paging stops at the + first message older than `--after` rather than walking to the cap. `truncated` then counts + only the messages inside the window: reaching the far edge is not a truncation, but a cap + that cut in-window messages short still is. The cap stays 200 for both chat and channel messages — a window that needs more than + 200 messages should be narrowed. `--after`/`--before` do not apply to `--replies` (exit 2), and + `--after` later than `--before` is a usage error. + Without `--after`/`--before` the request is byte-for-byte what it was. + Each message and reply carries `teamId` and `channelId`, which Graph omits on this collection, + so a message can be routed back to its channel. + +### `teams channel send TEAM CHANNEL` + +| Option | Default | Meaning | +|---|---|---| +| `--body TEXT` | — | Message text. | +| `--body-file FILE\|-` | — | File holding the text, or `-` for stdin. | +| `--html` | off | Send the body as HTML. | +| `--subject TEXT` | — | Message subject. | +| `--reply-to MSGID` | — | Reply inside that message's thread. | +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `POST /teams/{t}/channels/{c}/messages`, or `…/messages/{m}/replies` with + `--reply-to`, body `{subject?, body:{contentType:"text"|"html", content}}`. +- **Scopes:** `ChannelMessage.Send` +- **Notes:** write. Plain text is sent as `contentType:"text"`, so nothing needs escaping. diff --git a/skills/mgraphctl/reference/commands/todo.md b/skills/mgraphctl/reference/commands/todo.md new file mode 100644 index 0000000..fee366f --- /dev/null +++ b/skills/mgraphctl/reference/commands/todo.md @@ -0,0 +1,110 @@ +# `todo` + +Conventions, argument resolution, paging defaults, exit codes and environment variables +are in [`../commands.md`](../commands.md). + +`LIST` accepts a list id, the well-known names `defaultList` and `flaggedEmails`, or a list's +display name. + +### `todo lists` + +| Option | Default | Meaning | +|---|---|---| +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/todo/lists?$top=100`. +- **Scopes:** `Tasks.ReadWrite` +- **Notes:** shows each list's `wellknownListName` where it has one. + +### `todo tasks LIST` + +| Option | Default | Meaning | +|---|---|---| +| `--include-completed` | off | Also show completed tasks. | +| `--limit N` | 50 | Maximum items. | +| `--all` | off | Fetch every page, cap 500. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/todo/lists/{l}/tasks?$top=100[&$filter=status ne 'completed']` with + `Prefer: outlook.timezone`. +- **Scopes:** `Tasks.ReadWrite` +- **Notes:** columns: id, status, importance, due, title. + +### `todo task LIST ID` + +| Option | Default | Meaning | +|---|---|---| +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/todo/lists/{l}/tasks/{t}?$expand=checklistItems,linkedResources` with + `Prefer: outlook.timezone`. +- **Scopes:** `Tasks.ReadWrite` + +### `todo create LIST` + +| Option | Default | Meaning | +|---|---|---| +| `--title TEXT` | — | Task title. Required. | +| `--due DT` | — | Due date. | +| `--body TEXT` | — | Task notes. | +| `--importance LEVEL` | — | `low`, `normal` or `high`. | +| `--reminder DT` | — | Reminder time; also turns the reminder on. | +| `--start DT` | — | Start date. | +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `POST /me/todo/lists/{l}/tasks {title, body, importance, dueDateTime, reminderDateTime, + isReminderOn, startDateTime}`, dates sent in the active time zone. +- **Scopes:** `Tasks.ReadWrite` +- **Notes:** write. + +### `todo update LIST ID` + +| Option | Default | Meaning | +|---|---|---| +| `--title` / `--due` / `--body` / `--importance` / `--reminder` / `--start` | — | The `todo create` fields. | +| `--status STATE` | — | `notStarted`, `inProgress`, `completed`, `waitingOnOthers`, `deferred`. | +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `PATCH /me/todo/lists/{l}/tasks/{t}` with only the fields given. +- **Scopes:** `Tasks.ReadWrite` +- **Notes:** write. + +### `todo complete LIST ID` + +| Option | Default | Meaning | +|---|---|---| +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `PATCH /me/todo/lists/{l}/tasks/{t} {status:"completed"}`. +- **Scopes:** `Tasks.ReadWrite` +- **Notes:** write. + +### `todo delete LIST ID` + +| Option | Default | Meaning | +|---|---|---| +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `DELETE /me/todo/lists/{l}/tasks/{t}`. +- **Scopes:** `Tasks.ReadWrite` +- **Notes:** write. + +### `todo from-mail LIST MSGID` + +| Option | Default | Meaning | +|---|---|---| +| `--title TEXT` | the message subject | Task title. | +| `--due DT` | — | Due date. | +| `--importance LEVEL` | — | `low`, `normal` or `high`. | +| `--dry-run` | off | Show the request(s); send nothing. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me/messages/{id}?$select=subject,webLink,bodyPreview,from,receivedDateTime`, then + `POST /me/todo/lists/{l}/tasks` with the sender and preview in the body and a `linkedResources` + entry pointing back at the message in Outlook. +- **Scopes:** `Tasks.ReadWrite`, `Mail.Read` +- **Notes:** write. The task carries a working link back to the original mail. diff --git a/skills/mgraphctl/reference/commands/top-level.md b/skills/mgraphctl/reference/commands/top-level.md new file mode 100644 index 0000000..58f7323 --- /dev/null +++ b/skills/mgraphctl/reference/commands/top-level.md @@ -0,0 +1,124 @@ +# Top-level + +Conventions, argument resolution, paging defaults, exit codes and environment variables +are in [`../commands.md`](../commands.md). + +### `login` + +The only command that may open a browser. Run it yourself in your own terminal. + +| Option | Default | Meaning | +|---|---|---| +| `--scopes SET` | `MGRAPHCTL_SCOPES`, else `default` | `default`, `extended`, or a space/comma-separated scope list. | +| `--scope X` | — | One extra on-demand scope. Repeatable. | +| `--force` | off | Sign in again even when a token is cached. | +| `--device-code` | off | Device-code flow, for a host with no browser. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** the msal token endpoint, then `GET /me?$select=id,displayName,userPrincipalName`. +- **Scopes:** none checked (this is what obtains them). +- **Notes:** already signed in and no new scopes asked for → prints `Already logged in as: `, + exit 0. Interactive login times out after 300 s (`error[LOGIN_TIMEOUT]`, exit 3). Re-running with + `--scopes extended` on a consented account adds scopes with one consent prompt, no re-login. + The `Cache:` line (JSON `cache`, with `store` = `keyring` or `file`) names the OS keychain or + the cache file, whichever `token_store` resolved to. + +### `logout` + +| Option | Default | Meaning | +|---|---|---| +| `--json` | off | Print JSON instead of text. | + +- **Graph:** none. +- **Scopes:** none. +- **Notes:** removes the keychain item and `~/.mgraphctl/token_cache.json`, whichever exist. + Exit 0 whether or not a cache existed; JSON `{"loggedOut", "cache", "store"}`. + +### `status` + +| Option | Default | Meaning | +|---|---|---| +| `--json` | off | Print JSON instead of text. | + +- **Graph:** none — a silent token acquisition, which may refresh. +- **Scopes:** none. +- **Notes:** run this first. Logged in → `Logged in as`, `Token expires`, `Scopes`, `Cache`, exit 0. + Not logged in → `error[NOT_LOGGED_IN]` on stderr, exit 3. With `--json` stdout carries + `{"loggedIn": …}` in both states, so it can be parsed without checking the exit code first. + `cache` and `store` say where the sign-in lives: the OS keychain or the file. + +### `claims` + +| Option | Default | Meaning | +|---|---|---| +| `--json` | off | Print the raw token payload. | + +- **Graph:** none — a local JWT decode, no network call. +- **Scopes:** none. +- **Notes:** sections IDENTITY, DEVICE, AUTH METHODS, SCOPES. It cannot refresh an expired token; + no cached token → exit 3. + +### `me` + +| Option | Default | Meaning | +|---|---|---| +| `--photo PATH` | — | Save the profile photo to this file instead of printing the profile. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `GET /me?$select=id,displayName,userPrincipalName,mail,jobTitle,department,officeLocation,businessPhones,mobilePhone,preferredLanguage`; + `--photo`: `GET /me/photo/$value` (streamed). +- **Scopes:** `User.Read` +- **Notes:** a mailbox with no photo returns 404 → exit 4. + +### `version` + +| Option | Default | Meaning | +|---|---|---| +| `--json` | off | Print JSON instead of text. | + +- **Graph:** none. +- **Scopes:** none. +- **Notes:** prints `mgraphctl ` with the Python, msal and httpx versions it runs on. + +### `api METHOD PATH` + +The escape hatch: one raw Graph request. `PATH` may be relative (`/me/messages`) or absolute. + +| Option | Default | Meaning | +|---|---|---| +| `--query k=v` | — | Query parameter. Repeatable. | +| `--body JSON\|@FILE` | — | JSON request body, or `@FILE` holding it. | +| `--header k:v` | — | Extra request header. Repeatable. | +| `--beta` | off | Send this call to `/beta`. | +| `--all` | off | Follow `@odata.nextLink` and merge every page's `value`. | +| `--raw` | off | Treat the response as bytes, not JSON. | +| `--output FILE` | — | Write the `--raw` bytes to this file. | +| `--outlook-tz` | off | Send `Prefer: outlook.timezone` for mail/calendar paths. Hidden from `--help`. | +| `--dry-run` | off | Show the request; send nothing. | +| `--json` | off | Accepted, no-op — the body is printed as returned. | + +- **Graph:** exactly the request given. +- **Scopes:** none declared; the local gate is skipped and Graph decides. +- **Notes:** the response body is printed as-is; a non-JSON body prints as text unless `--raw`. + Any non-GET `api` call is a write — dry-run it and confirm first. + +### `search Q` + +| Option | Default | Meaning | +|---|---|---| +| `--type TYPE` | `message` | `message`, `event`, `driveItem`, `site`, `list`, `chatMessage`, or `person`. | +| `--after DT` | — | Only hits after this time. | +| `--before DT` | — | Only hits before this time. | +| `--limit N` | 25 | Maximum items. | +| `--all` | off | Fetch every page, cap 200. | +| `--fields a,b` | — | Comma-separated Graph fields to fetch. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** `POST /search/query {requests:[{entityTypes:[type], query:{queryString}, from, size:25, fields?}]}`, + paging on `moreResultsAvailable`. +- **Scopes:** checked per `--type` after parsing: `message` → `Mail.Read`; `event` → `Calendars.Read`; + `driveItem`/`site`/`list` → `Sites.Read.All`; `chatMessage` → `Chat.Read`; `person` → `People.Read`. +- **Notes:** for `message` the date window is appended to the KQL as `received>=`/`received<=`; for + every other type it is applied client-side. Hits carry a `summary` snippet, not the whole item. + `chatMessage` hits are labelled `chat:` or `channel:/`, exactly as + `chats search` labels them. diff --git a/src/mgraphctl/__init__.py b/src/mgraphctl/__init__.py index ca5cf7c..078da65 100644 --- a/src/mgraphctl/__init__.py +++ b/src/mgraphctl/__init__.py @@ -1,3 +1,3 @@ """mgraphctl: Microsoft Graph CLI for the mgraphctl skill.""" -__version__ = "0.2.0" +__version__ = "0.2.1" diff --git a/tests/test_docs.py b/tests/test_docs.py index 32d7aef..90f657c 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -1,4 +1,4 @@ -"""Every registered verb is documented in reference/commands.md, and SKILL.md stays short.""" +"""Every registered verb is documented under reference/commands/, and SKILL.md stays short.""" import re from pathlib import Path @@ -7,19 +7,36 @@ SKILL_DIR = Path(__file__).resolve().parents[1] / "skills" / "mgraphctl" SHIM = "${CLAUDE_PLUGIN_ROOT}/mgraphctl" +COMMANDS = SKILL_DIR / "reference" / "commands" +INDEX = SKILL_DIR / "reference" / "commands.md" # Every verb gets its own `### `noun verb ARGS`` heading; ARGS are upper case, so they stop the # match and `presence set available` still counts as documentation for `presence set`. HEADING = re.compile(r"^### `([a-z0-9-]+(?: [a-z0-9-]+)*)", re.M) +# The index links each noun file once, as `[`commands/.md`](commands/.md)`. +INDEX_LINK = re.compile(r"\]\(commands/([a-z0-9-]+\.md)\)") -def test_every_verb_has_a_commands_md_heading(app): - headings = HEADING.findall((SKILL_DIR / "reference" / "commands.md").read_text()) +def test_every_verb_has_a_command_file_heading(app): + headings = [h for path in COMMANDS.glob("*.md") for h in HEADING.findall(path.read_text())] missing = [ verb for verb in sorted(walk(app)) if not any(h == verb or h.startswith(f"{verb} ") for h in headings) ] - assert not missing, f"verbs missing from reference/commands.md: {missing}" + assert not missing, f"verbs missing from reference/commands/: {missing}" + + +def test_the_index_links_every_command_file(): + """A file nobody links to is a file nobody reads; a dead link sends the skill nowhere.""" + linked = set(INDEX_LINK.findall(INDEX.read_text())) + on_disk = {path.name for path in COMMANDS.glob("*.md")} + assert linked == on_disk + + +def test_command_files_link_back_to_the_shared_conventions(): + """Paging, argument resolution and exit codes live only in the index.""" + for path in sorted(COMMANDS.glob("*.md")): + assert "(../commands.md)" in path.read_text(), path.name def test_skill_md_is_short_and_names_the_shim(): diff --git a/uv.lock b/uv.lock index a7dc273..ad9485f 100644 --- a/uv.lock +++ b/uv.lock @@ -446,7 +446,7 @@ wheels = [ [[package]] name = "mgraphctl" -version = "0.2.0" +version = "0.2.1" source = { editable = "." } dependencies = [ { name = "httpx" },