ask-marcel-office-cli 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/README.md +178 -119
  3. package/dist/cli.js +44806 -27142
  4. package/dist/commands.json +371 -52
  5. package/dist/composition/build-deps.d.ts +8 -0
  6. package/dist/composition/mcp.d.ts +20 -0
  7. package/dist/composition/run-registry-command.d.ts +34 -0
  8. package/dist/domain/tenant-id.d.ts +9 -0
  9. package/dist/domain/utilities/spo-tenant.d.ts +20 -0
  10. package/dist/index.js +2735 -1674
  11. package/dist/infra/auth.d.ts +36 -14
  12. package/dist/infra/browser-auth.d.ts +7 -2
  13. package/dist/infra/graph-client.d.ts +52 -16
  14. package/dist/presenter/graph-cursor.d.ts +2 -0
  15. package/dist/presenter/output.d.ts +1 -1
  16. package/dist/presenter/render-to-string.d.ts +15 -0
  17. package/dist/use-cases/commands/build-command.d.ts +19 -2
  18. package/dist/use-cases/commands/command-types.d.ts +16 -11
  19. package/dist/use-cases/commands/convert-calendar-event-attachment-to-markdown.d.ts +4 -0
  20. package/dist/use-cases/commands/{convert-drive-item-zip.d.ts → convert-drive-item-zip-to-markdown.d.ts} +5 -0
  21. package/dist/use-cases/commands/{convert-local-file.d.ts → convert-local-file-to-markdown.d.ts} +4 -0
  22. package/dist/use-cases/commands/convert-mail-attachment-to-markdown.d.ts +10 -2
  23. package/dist/use-cases/commands/{convert-mail-attachment-zip.d.ts → convert-mail-attachment-zip-to-markdown.d.ts} +5 -1
  24. package/dist/use-cases/commands/convert-mail-to-markdown.d.ts +35 -1
  25. package/dist/use-cases/commands/create-forward-draft.d.ts +4 -0
  26. package/dist/use-cases/commands/create-reply-draft.d.ts +8 -0
  27. package/dist/use-cases/commands/download-drive-item-as-markdown.d.ts +7 -2
  28. package/dist/use-cases/commands/download-drive-item-as-pdf.d.ts +1 -0
  29. package/dist/use-cases/commands/download-drive-item-content.d.ts +1 -0
  30. package/dist/use-cases/commands/draft-comment-splicer.d.ts +29 -0
  31. package/dist/use-cases/commands/draft-dedup.d.ts +18 -0
  32. package/dist/use-cases/commands/draft-response.d.ts +4 -0
  33. package/dist/use-cases/commands/extract-drive-item-images.d.ts +1 -0
  34. package/dist/use-cases/commands/extract-local-file-images.d.ts +1 -1
  35. package/dist/use-cases/commands/fetch-raw-bytes.d.ts +13 -0
  36. package/dist/use-cases/commands/find-mail-drafts.d.ts +9 -0
  37. package/dist/use-cases/commands/get-mail-signature.d.ts +8 -0
  38. package/dist/use-cases/commands/get-user.d.ts +10 -0
  39. package/dist/use-cases/commands/inline-image-embedder.d.ts +2 -1
  40. package/dist/use-cases/commands/login-status.d.ts +9 -27
  41. package/dist/use-cases/commands/login.d.ts +21 -0
  42. package/dist/use-cases/commands/mail-message-select.d.ts +1 -0
  43. package/dist/use-cases/commands/mail-quote-stripper.d.ts +16 -15
  44. package/dist/use-cases/commands/markdown-dispatch.d.ts +2 -1
  45. package/dist/use-cases/commands/msg-to-markdown.d.ts +10 -1
  46. package/dist/use-cases/commands/odata-query.d.ts +15 -1
  47. package/dist/use-cases/commands/office-to-markdown.d.ts +1 -0
  48. package/dist/use-cases/commands/read-mail-attachment.d.ts +4 -0
  49. package/dist/use-cases/commands/resolve-command.d.ts +25 -0
  50. package/dist/use-cases/commands/search-all-files.d.ts +8 -0
  51. package/dist/use-cases/commands/search-escape.d.ts +23 -0
  52. package/dist/use-cases/commands/signature-extractor.d.ts +2 -0
  53. package/dist/use-cases/commands/tenant-option.d.ts +44 -0
  54. package/dist/use-cases/commands/update-mail-draft.d.ts +1 -0
  55. package/dist/use-cases/commands/zip-archive-to-markdown.d.ts +10 -5
  56. package/dist/use-cases/ports/filesystem.d.ts +1 -1
  57. package/docs/COMMANDS.md +34 -29
  58. package/docs/USAGE.md +65 -7
  59. package/package.json +2 -1
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "package": "ask-marcel-office-cli",
3
- "version": "2.1.0",
4
- "generatedAt": "2026-07-13T10:43:42.181Z",
3
+ "version": "2.2.0",
4
+ "generatedAt": "2026-07-19T12:40:32.946Z",
5
5
  "commands": [
6
6
  {
7
7
  "name": "convert-calendar-event-attachment-to-markdown",
@@ -35,6 +35,19 @@
35
35
  "false"
36
36
  ]
37
37
  }
38
+ },
39
+ {
40
+ "name": "keep-quoted",
41
+ "key": "keepQuoted",
42
+ "required": false,
43
+ "description": "Applies to Outlook `.msg` input only. The quoted reply chain / forwarded-message block is stripped by default (it duplicates history and inflates the context budget) and replaced with a single visible marker naming this flag, so nothing is removed silently. Pass `--keep-quoted true` to render the full body. The recognized markers are the same set `convert-mail-to-markdown` uses — see `ask-marcel-office docs convert-mail-to-markdown`.",
44
+ "argumentHint": {
45
+ "kind": "magicValue",
46
+ "values": [
47
+ "true",
48
+ "false"
49
+ ]
50
+ }
38
51
  }
39
52
  ],
40
53
  "example": "ask-marcel-office convert-calendar-event-attachment-to-markdown --event-id 'AAMkAD...' --attachment-id 'AAMkAD...attach1'",
@@ -67,9 +80,12 @@
67
80
  "producesBytes": true
68
81
  },
69
82
  {
70
- "name": "convert-drive-item-zip",
83
+ "name": "convert-drive-item-zip-to-markdown",
71
84
  "summary": "Unzip a `.zip` from a OneDrive / SharePoint item and convert every contained file in one call — so \"read the handover archive\" doesn't need a separate unzip + per-file conversion. Office files (docx/xlsx/pptx/odt/ods/odp and their macro-enabled / template variants) are converted to markdown via the local pipelines; plain-text entries (txt/md/csv/json/yaml/…) are decoded inline; legacy OLE .xls (sheetjs) and .doc (word-extractor, text only) are extracted; an Outlook .msg entry is rendered to markdown (headers + body, with its own attachments converted recursively); PDFs have their text layer extracted (text/plain); images, binaries, nested archives, legacy .ppt, and scanned/image-only PDFs (no text layer) are listed with a note (not unpacked) so one unsupported entry never fails the whole archive. Pass `--include-metadata true` to append each Office file's side-channel metadata block. Capped at 100 entries (the archive is buffered in memory); beyond that the response is flagged `truncated`.",
72
85
  "category": "drive",
86
+ "commandAliases": [
87
+ "convert-drive-item-zip"
88
+ ],
73
89
  "graphMethod": "GET",
74
90
  "graphPathTemplate": "/drives/{drive-id}/items/{item-id}/content",
75
91
  "graphDocsUrl": "https://learn.microsoft.com/en-us/graph/api/driveitem-get-content",
@@ -86,6 +102,12 @@
86
102
  "required": true,
87
103
  "description": "driveItem ID of the .zip file. Returned by `list-folder-files` or `search-onedrive-files`."
88
104
  },
105
+ {
106
+ "name": "tenant-id",
107
+ "key": "tenantId",
108
+ "required": false,
109
+ "description": "Tenant GUID of a PARTNER tenant you are a guest in, when the file does not live in your own tenant. Get it from `resolve-drive-share-link`, which returns `tenantId` on any sharing URL whose file belongs to another tenant — that is the only place the tenant is knowable, because `--drive-id` and `--item-id` carry no tenant at all. Omit it for files in your own tenant (the normal case). Pass it and the request is signed with a guest token minted for that tenant instead of your home token; without it, a partner-tenant file fails with `invalidAudienceUri` no matter which of your tokens is used."
110
+ },
89
111
  {
90
112
  "name": "include-metadata",
91
113
  "key": "includeMetadata",
@@ -98,15 +120,31 @@
98
120
  "false"
99
121
  ]
100
122
  }
123
+ },
124
+ {
125
+ "name": "keep-quoted",
126
+ "key": "keepQuoted",
127
+ "required": false,
128
+ "description": "Applies to Outlook `.msg` input only. The quoted reply chain / forwarded-message block is stripped by default (it duplicates history and inflates the context budget) and replaced with a single visible marker naming this flag, so nothing is removed silently. Pass `--keep-quoted true` to render the full body. The recognized markers are the same set `convert-mail-to-markdown` uses — see `ask-marcel-office docs convert-mail-to-markdown`.",
129
+ "argumentHint": {
130
+ "kind": "magicValue",
131
+ "values": [
132
+ "true",
133
+ "false"
134
+ ]
135
+ }
101
136
  }
102
137
  ],
103
- "example": "ask-marcel-office convert-drive-item-zip --drive-id 'b!1234' --item-id '01ABC'",
138
+ "example": "ask-marcel-office convert-drive-item-zip-to-markdown --drive-id 'b!1234' --item-id '01ABC'",
104
139
  "responseShape": "`{ count, files: [{ path, contentType, size, text }] }` — one entry per file in the archive (sorted by path). Convertible files carry `{ contentType, size, text }` (the markdown); unsupported / failed entries carry `{ path, note }` instead. When the archive has more than 100 entries the response adds `truncated: true` + `totalEntries` and only the first 100 are converted."
105
140
  },
106
141
  {
107
- "name": "convert-local-file",
108
- "summary": "Convert a file ON DISK to markdown — the only command that never calls Microsoft Graph (works offline, no login). Runs the same local pipelines as `download-drive-item-as-markdown`: docx (mammoth → turndown), xlsx (sheetjs tables, `--max-cells` OOM cap), pptx (per-slide text), odt/ods/odp, csv, pdf (text layer via unpdf), legacy OLE .xls / .doc, Outlook .msg (headers + body, attachments converted recursively), plain-text passthrough — and a `.zip` is unpacked with every contained file converted in one call (legacy GBK / CP437 entry names decoded, not mojibaked). What it canNOT do locally: convert TO pdf, and Loop/Fluid/Whiteboard sources — both need a Graph server round-trip (upload to OneDrive and use the drive-item siblings). Pass `--include-metadata true` for the Office side-channel metadata blocks; `--inline-images true` to embed docx images as base64 data URIs.",
142
+ "name": "convert-local-file-to-markdown",
143
+ "summary": "Convert a file ON DISK to markdown — the only command that never calls Microsoft Graph (works offline, no login). Runs the same local pipelines as `download-drive-item-as-markdown`: docx (mammoth → turndown), xlsx (sheetjs tables, `--max-cells` OOM cap), pptx (per-slide text), odt/ods/odp, csv, pdf (text layer via unpdf), legacy OLE .xls / .doc, Outlook .msg (headers + body with the quoted reply chain stripped — `--keep-quoted true` restores it — and inline `cid:` images shown as placeholders, attachments converted recursively), plain-text passthrough — and a `.zip` is unpacked with every contained file converted in one call (legacy GBK / CP437 entry names decoded, not mojibaked). What it canNOT do locally: convert TO pdf, and Loop/Fluid/Whiteboard sources — both need a Graph server round-trip (upload to OneDrive and use the drive-item siblings). Pass `--include-metadata true` for the Office side-channel metadata blocks; `--inline-images true` to embed docx images as base64 data URIs.",
109
144
  "category": "meta",
145
+ "commandAliases": [
146
+ "convert-local-file"
147
+ ],
110
148
  "graphMethod": "GET",
111
149
  "graphPathTemplate": "(local) reads {path} from the local filesystem; not a Graph endpoint",
112
150
  "graphDocsUrl": "https://learn.microsoft.com/en-us/graph/",
@@ -130,6 +168,19 @@
130
168
  ]
131
169
  }
132
170
  },
171
+ {
172
+ "name": "keep-quoted",
173
+ "key": "keepQuoted",
174
+ "required": false,
175
+ "description": "Applies to Outlook `.msg` input only. The quoted reply chain / forwarded-message block is stripped by default (it duplicates history and inflates the context budget) and replaced with a single visible marker naming this flag, so nothing is removed silently. Pass `--keep-quoted true` to render the full body. The recognized markers are the same set `convert-mail-to-markdown` uses — see `ask-marcel-office docs convert-mail-to-markdown`.",
176
+ "argumentHint": {
177
+ "kind": "magicValue",
178
+ "values": [
179
+ "true",
180
+ "false"
181
+ ]
182
+ }
183
+ },
133
184
  {
134
185
  "name": "inline-images",
135
186
  "key": "inlineImages",
@@ -163,13 +214,13 @@
163
214
  "description": "Per-sheet cell cap (positive integer; default 50 000) for xlsx/csv sources. A sheet whose used range exceeds the cap renders as a truncation hint instead of a multi-hundred-MB table. No-op on other sources."
164
215
  }
165
216
  ],
166
- "example": "ask-marcel-office convert-local-file --path ./report.docx",
217
+ "example": "ask-marcel-office convert-local-file-to-markdown --path ./report.docx",
167
218
  "responseShape": "`{ contentType: \"text/markdown\" | \"text/plain\", size, text }` for a single file; `{ count, files: [{ path, contentType, size, text } | { path, note }] }` for a `.zip` (one entry per contained file, unsupported entries noted). With `--include-images true` each `.zip` entry also carries `images: [{ path, contentType, sizeBytes, base64 }]` when it has extractable embedded images. A missing file returns api_error 404 with the path. Pair with the global `--output-path` to land the markdown on disk.",
168
219
  "producesBytes": true
169
220
  },
170
221
  {
171
222
  "name": "convert-mail-attachment-to-markdown",
172
- "summary": "Convert an Outlook mail attachment to markdown. Polymorphic on the attachment’s `@odata.type`: fileAttachment decodes the inline bytes and runs them through the local conversion pipeline (docx via mammoth, xlsx via sheetjs, csv as markdown table, odt/ods/odp via content.xml, pptx as per-slide text (titles + bullets + speaker notes inline), pdf via text-layer extraction (unpdf → text/plain), legacy .xls via sheetjs and legacy .doc via word-extractor (text only), an Outlook .msg attachment rendered to markdown — headers + body with its own attachments converted recursively — plus plain-text passthrough); referenceAttachment resolves via /shares/{token}/driveItem and routes through the same dispatcher; itemAttachment (embedded mail / event / contact) is rendered locally via dedicated renderers. For pptx layout / images, `convert-mail-attachment-to-pdf` + a vision model reads the rendered deck better. A scanned / image-only PDF (no text layer), legacy .ppt, and rtf/etc. point to the PDF sibling. Loop/Fluid/Whiteboard reference-attachments use Graph `?format=html` (the four inputs Microsoft documents).",
223
+ "summary": "Convert an Outlook mail attachment to markdown. Polymorphic on the attachment’s `@odata.type`: fileAttachment decodes the inline bytes and runs them through the local conversion pipeline (docx via mammoth, xlsx via sheetjs, csv as markdown table, odt/ods/odp via content.xml, pptx as per-slide text (titles + bullets + speaker notes inline), pdf via text-layer extraction (unpdf → text/plain), legacy .xls via sheetjs and legacy .doc via word-extractor (text only), an Outlook .msg attachment rendered to markdown — headers + body (quoted reply chain stripped unless `--keep-quoted true`; inline `cid:` images shown as placeholders) with its own attachments converted recursively — plus plain-text passthrough); referenceAttachment resolves via /shares/{token}/driveItem and routes through the same dispatcher; itemAttachment (embedded mail / event / contact) is rendered locally via dedicated renderers. For pptx layout / images, `convert-mail-attachment-to-pdf` + a vision model reads the rendered deck better. A scanned / image-only PDF (no text layer), legacy .ppt, and rtf/etc. point to the PDF sibling. Loop/Fluid/Whiteboard reference-attachments use Graph `?format=html` (the four inputs Microsoft documents).",
173
224
  "category": "mail",
174
225
  "graphMethod": "GET",
175
226
  "graphPathTemplate": "/me/messages/{message-id}/attachments/{attachment-id}",
@@ -199,6 +250,19 @@
199
250
  "false"
200
251
  ]
201
252
  }
253
+ },
254
+ {
255
+ "name": "keep-quoted",
256
+ "key": "keepQuoted",
257
+ "required": false,
258
+ "description": "Applies to Outlook `.msg` input only. The quoted reply chain / forwarded-message block is stripped by default (it duplicates history and inflates the context budget) and replaced with a single visible marker naming this flag, so nothing is removed silently. Pass `--keep-quoted true` to render the full body. The recognized markers are the same set `convert-mail-to-markdown` uses — see `ask-marcel-office docs convert-mail-to-markdown`.",
259
+ "argumentHint": {
260
+ "kind": "magicValue",
261
+ "values": [
262
+ "true",
263
+ "false"
264
+ ]
265
+ }
202
266
  }
203
267
  ],
204
268
  "example": "ask-marcel-office convert-mail-attachment-to-markdown --message-id 'AAMkAD...' --attachment-id 'AAMkAD...attach1'",
@@ -231,9 +295,12 @@
231
295
  "producesBytes": true
232
296
  },
233
297
  {
234
- "name": "convert-mail-attachment-zip",
235
- "summary": "Unzip a `.zip` Outlook mail attachment and convert every contained file in one call — the mail-side mirror of `convert-drive-item-zip`, so reading a zipped vendor deck doesn't need `get-mail-attachment` + manual `unzip` + per-file conversion. Pulls the fileAttachment bytes, unzips them (legacy GBK / CP437 entry names — Chinese vendor archives written by WinRAR / Windows Explorer — are decoded correctly, not mojibaked), and runs each file through the local pipelines: Office files (docx/xlsx/pptx/odt/ods/odp and macro-enabled / template variants) → markdown; plain-text entries decoded inline; legacy OLE .xls (sheetjs) and .doc (word-extractor, text only) extracted; an inner Outlook .msg rendered; PDFs have their text layer extracted; images, binaries, nested archives, legacy .ppt, and scanned/image-only PDFs are listed with a note (not unpacked) so one unsupported entry never fails the whole archive. Pass `--include-metadata true` to append each Office file's side-channel metadata block. Capped at 100 entries; beyond that the response is flagged `truncated`. itemAttachment / referenceAttachment are rejected (no inline zip payload).",
298
+ "name": "convert-mail-attachment-zip-to-markdown",
299
+ "summary": "Unzip a `.zip` Outlook mail attachment and convert every contained file in one call — the mail-side mirror of `convert-drive-item-zip-to-markdown`, so reading a zipped vendor deck doesn't need `get-mail-attachment` + manual `unzip` + per-file conversion. Pulls the fileAttachment bytes, unzips them (legacy GBK / CP437 entry names — Chinese vendor archives written by WinRAR / Windows Explorer — are decoded correctly, not mojibaked), and runs each file through the local pipelines: Office files (docx/xlsx/pptx/odt/ods/odp and macro-enabled / template variants) → markdown; plain-text entries decoded inline; legacy OLE .xls (sheetjs) and .doc (word-extractor, text only) extracted; an inner Outlook .msg rendered; PDFs have their text layer extracted; images, binaries, nested archives, legacy .ppt, and scanned/image-only PDFs are listed with a note (not unpacked) so one unsupported entry never fails the whole archive. Pass `--include-metadata true` to append each Office file's side-channel metadata block. Capped at 100 entries; beyond that the response is flagged `truncated`. itemAttachment / referenceAttachment are rejected (no inline zip payload).",
236
300
  "category": "mail",
301
+ "commandAliases": [
302
+ "convert-mail-attachment-zip"
303
+ ],
237
304
  "graphMethod": "GET",
238
305
  "graphPathTemplate": "/me/messages/{message-id}/attachments/{attachment-id}",
239
306
  "graphDocsUrl": "https://learn.microsoft.com/en-us/graph/api/attachment-get",
@@ -262,14 +329,27 @@
262
329
  "false"
263
330
  ]
264
331
  }
332
+ },
333
+ {
334
+ "name": "keep-quoted",
335
+ "key": "keepQuoted",
336
+ "required": false,
337
+ "description": "Applies to Outlook `.msg` input only. The quoted reply chain / forwarded-message block is stripped by default (it duplicates history and inflates the context budget) and replaced with a single visible marker naming this flag, so nothing is removed silently. Pass `--keep-quoted true` to render the full body. The recognized markers are the same set `convert-mail-to-markdown` uses — see `ask-marcel-office docs convert-mail-to-markdown`.",
338
+ "argumentHint": {
339
+ "kind": "magicValue",
340
+ "values": [
341
+ "true",
342
+ "false"
343
+ ]
344
+ }
265
345
  }
266
346
  ],
267
- "example": "ask-marcel-office convert-mail-attachment-zip --message-id 'AAMkAD...' --attachment-id 'AAMkAD...attach1'",
347
+ "example": "ask-marcel-office convert-mail-attachment-zip-to-markdown --message-id 'AAMkAD...' --attachment-id 'AAMkAD...attach1'",
268
348
  "responseShape": "`{ count, files: [{ path, contentType, size, text }] }` — one entry per file in the archive (sorted by path; non-mojibake names). Convertible files carry `{ contentType, size, text }` (the markdown); unsupported / failed entries carry `{ path, note }`. When the archive has more than 100 entries the response adds `truncated: true` + `totalEntries` and only the first 100 are converted. A non-fileAttachment (itemAttachment / referenceAttachment) or a non-zip payload returns an api_error."
269
349
  },
270
350
  {
271
351
  "name": "convert-mail-to-markdown",
272
- "summary": "Render a single Outlook email as markdown — headers (`**Subject:**`, `**From:**`, `**To:**`, `**Cc:**` only when present, `**Date:**`), followed by the body run through turndown. By default, inline images (`isInline:true` + `image/*` content-type, size 2 MB) are embedded as base64 `data:` URIs so the output is self-contained (non-image inline attachments are NOT embedded; oversize inline images are replaced with a placeholder note). For LLM callers that only want the text body, pass `--inline-images false` to skip the per-image bytes fetch entirely the body keeps raw `cid:<contentId>` references and the inline images surface in the file-attachments list so you can decide whether to fetch them separately via `get-mail-attachment`. File attachments are always listed below the body by name + size + id; their bytes are NOT fetched here — call `convert-mail-attachment-to-pdf` or `get-mail-attachment` with the id when you actually need them. Staged-fetch design: one call for the body, one for the attachments-metadata list (only if `hasAttachments:true`), and one per small inline image — replaces the old `?$expand=attachments` which timed out / truncated on messages with multi-MB attachments.",
352
+ "summary": "Render a single Outlook email as markdown — headers (`**Subject:**`, `**From:**`, `**To:**`, `**Cc:**` only when present, `**Date:**`), followed by the body run through turndown. By default NO image bytes are fetched: every inline `cid:` image renders as a readable `[inline image: <name>]` placeholder and the images surface in the file-attachments list, so the output stays close to the text size (an email whose 6 KB body carried 30 KB of signature-image base64 now ships at ~6 KB). Pass `--inline-images true` to embed inline images (`isInline:true` + `image/*` content-type, size 2 MB) as base64 `data:` URIs for self-contained output (non-image inline attachments are never embedded; oversize inline images keep a placeholder note; a cid whose per-image fetch fails degrades to the placeholder too). File attachments are always listed below the body by name + size + id; their bytes are NOT fetched here — call `convert-mail-attachment-to-pdf` or `get-mail-attachment` with the id when you actually need them. Staged-fetch design: one call for the body, one for the attachments-metadata list (only if `hasAttachments:true`), and with `--inline-images true` one per small inline image — replaces the old `?$expand=attachments` which timed out / truncated on messages with multi-MB attachments.",
273
353
  "category": "mail",
274
354
  "graphMethod": "GET",
275
355
  "graphPathTemplate": "/me/messages/{message-id}",
@@ -291,13 +371,13 @@
291
371
  "name": "inline-images",
292
372
  "key": "inlineImages",
293
373
  "required": false,
294
- "description": "Pass `--inline-images false` to skip the per-image bytes fetch + base64 embedding. Default is `true` (embed). Disabling cuts the response size dramatically on emails with several inline images (a 6 KB body with 6 inline images shipped at ~36 KB by default; with `--inline-images false` it stays close to 6 KB). The body keeps raw `cid:<contentId>` references and the inline images surface in the file-attachments list instead, so the LLM caller can still see what is there and fetch any specific image via `get-mail-attachment` on demand."
374
+ "description": "Pass `--inline-images true` to fetch small inline images (≤ 2 MB, `image/*` only) and embed them as base64 `data:` URIs. Default is `false` (text-first): no per-image bytes fetch, and every inline `cid:` image renders as a `[inline image: <name>]` placeholder while still appearing in the file-attachments list, so the LLM caller sees what exists and can fetch any specific image via `get-mail-attachment` on demand. Embedding a 6-inline-image email inflated a 6 KB body to ~36 KB; the default keeps it near 6 KB."
295
375
  },
296
376
  {
297
377
  "name": "keep-quoted",
298
378
  "key": "keepQuoted",
299
379
  "required": false,
300
- "description": "Quoted reply chains and forwarded-message blocks are stripped by default (they duplicate content already present in earlier messages and inflate the context budget). The stripped tail is replaced with a single visible marker naming this flag, so nothing is removed silently. Pass `--keep-quoted true` to preserve the full body. Only well-known structural markers are cut: in HTML bodies Outlook `divRplyFwdMsg` / `appendonsend` / `stopSpelling` and Gmail `gmail_quote`; in plain-text bodies the `Original Message` banner, the `On … wrote:` attribution line, and leading `>` quote lines.",
380
+ "description": "Quoted reply chains and forwarded-message blocks are stripped by default (they duplicate content already present in earlier messages and inflate the context budget). The stripped tail is replaced with a single visible marker naming this flag, so nothing is removed silently. Pass `--keep-quoted true` to preserve the full body. Only well-known structural markers are cut: in HTML bodies Outlook `divRplyFwdMsg` / `appendonsend` / `stopSpelling`, Gmail `gmail_quote`, the Outlook reply border separator (`#E1E1E1` on desktop, `#B5C4DF` on Mac/mobile), and a bold `From:`/`Sent:` header-label pair (localized variants recognized: 发件人/发送时间, De/Envoyé, Von/Gesendet, Da/Inviato, De/Enviado, Van/Verzonden, 差出人/送信日時, 보낸 사람/보낸 날짜 — a lone bolded \"From:\" without its companion label never cuts); in plain-text bodies the `Original Message` banner, the `On … wrote:` attribution line, leading `>` quote lines, and the same localized `From:`+`Sent:` line pairs.",
301
381
  "argumentHint": {
302
382
  "kind": "magicValue",
303
383
  "values": [
@@ -350,23 +430,36 @@
350
430
  "name": "body-content",
351
431
  "key": "bodyContent",
352
432
  "required": true,
353
- "description": "The comment text, placed above the quoted forwarded message by Graph."
433
+ "description": "The comment text, placed above the quoted forwarded message. Plain text by default; pass --body-content-type HTML to send it as markup."
354
434
  },
355
435
  {
356
436
  "name": "subject",
357
437
  "key": "subject",
358
438
  "required": false,
359
439
  "description": "Optional subject override. Omit to keep the inherited \"FW: ...\" subject."
440
+ },
441
+ {
442
+ "name": "body-content-type",
443
+ "key": "bodyContentType",
444
+ "required": false,
445
+ "description": "Format of --body-content: Text (default) or HTML. Text is handed to Graph as the forward comment, which HTML-escapes it, so markup shows as literal characters. HTML instead creates the draft with an empty comment and splices your markup in at the TOP of the body — above Graph's separator (the `<hr>` line) and the forwarded original, so your comment leads the body content — leaving the original and its styles byte-identical. Rejected when your markup itself contains a quote boundary marker (a pasted reply chain), and when the original is a plain-text message.",
446
+ "argumentHint": {
447
+ "kind": "magicValue",
448
+ "values": [
449
+ "Text",
450
+ "HTML"
451
+ ]
452
+ }
360
453
  }
361
454
  ],
362
455
  "example": "ask-marcel-office create-forward-draft --forward-message-id \"AAMkAD...\" --to-recipients \"bob@example.com\" --body-content \"Bob owns this now, forwarding for your action.\"",
363
- "responseShape": "The updated draft message object (or `{ ok: true }` when Graph answers 204): `{ id, subject, body, toRecipients, ccRecipients, isDraft: true, }`. The `id` is the draft - update further with update-mail-draft, or open Outlook Drafts to review and send.",
364
- "bodyTemplate": "POST { comment: '{body-content}', toRecipients: '{to-recipients}' } then optional PATCH { ccRecipients?: '{cc-recipients}', subject?: '{subject}' }",
456
+ "responseShape": "A confirmation of the write, NOT the whole message: `{ id, subject, toRecipients, ccRecipients, bccRecipients, importance, bodyPreview, isDraft, webLink, conversationId }` (only the fields Graph returned; `{ ok: true }` when Graph answers 204). The `body` is deliberately omitted — you just wrote it, and echoing a long thread's quoted history back cost ~174 KB of context per call. Read the full body with `get-mail-message --id <the returned id>` when you actually need it; `bodyPreview` is Graph's ~255-char summary, enough to confirm WHICH draft answered. The `id` is the draft refine it with `update-mail-draft`, or open Outlook Drafts to review and send. Dedup caveat: a `conversationId` `$filter` on the Drafts folder is not a reliable 'does a draft already exist on this thread' check. Reply and forward drafts do not always inherit the inbound message's `conversationId` (one thread can split across several), and Graph `$filter` on Drafts is not read-your-writes consistent, so a just-created draft can be missed. To find existing drafts, use the `find-mail-drafts` command, which scans recent drafts and matches client-side on subject and recipients; to revise a draft this session created, reuse the returned `id`.",
457
+ "bodyTemplate": "Text: POST { comment: '{body-content}', toRecipients: '{to-recipients}' } then optional PATCH { ccRecipients?: '{cc-recipients}', subject?: '{subject}' }. HTML ({body-content-type}): POST { comment: '', toRecipients: '{to-recipients}' } then ONE PATCH { body: { contentType: 'HTML', content: <'{body-content}' spliced at the top of the body, above Graph's <hr> separator and the quote> }, ccRecipients?: '{cc-recipients}', subject?: '{subject}' }",
365
458
  "mutates": true
366
459
  },
367
460
  {
368
461
  "name": "create-mail-draft",
369
- "summary": "Create a new mail draft. POST /me/messages (or /me/mailFolders/{id}/messages when --mail-folder-id is set). The draft is saved in the Drafts folder (or the specified folder) and can be sent later via the Outlook client or Graph sendMail. Recipients are comma-separated email addresses. Returns the created message object with its id use this id with update-mail-draft to modify the draft before sending.",
462
+ "summary": "Create a new mail draft. POST /me/messages (or /me/mailFolders/{id}/messages when --mail-folder-id is set). The draft is saved in the Drafts folder (or the specified folder) and can be sent later via the Outlook client or Graph sendMail. Recipients are comma-separated email addresses. Returns a slim confirmation (id, subject, recipients, importance, bodyPreview, …) - NOT the body you just wrote; read the full draft back with get-mail-message if you need it. Use the returned id with update-mail-draft to modify before sending.",
370
463
  "category": "mail",
371
464
  "graphMethod": "POST",
372
465
  "graphPathTemplate": "/me/messages (or /me/mailFolders/{mail-folder-id}/messages)",
@@ -440,16 +533,16 @@
440
533
  }
441
534
  ],
442
535
  "example": "ask-marcel-office create-mail-draft --subject \"Q3 Report\" --body-content \"Please review the attached report.\" --to-recipients \"alice@example.com,bob@example.com\" --importance High",
443
- "responseShape": "The created Microsoft Graph message object: `{ id, subject, body, from, toRecipients, ccRecipients, bccRecipients, receivedDateTime, isDraft, }`. The `id` field is the draft message ID use it with `update-mail-draft` to modify before sending.",
536
+ "responseShape": "A confirmation of the write, NOT the whole message: `{ id, subject, toRecipients, ccRecipients, bccRecipients, importance, bodyPreview, isDraft, webLink, conversationId }` (only the fields Graph returned; `{ ok: true }` when Graph answers 204). The `body` is deliberately omitted — you just supplied it; read it back with `get-mail-message --id <the returned id>` if you need it. The `id` is the draft — modify it with `update-mail-draft` before sending.",
444
537
  "bodyTemplate": "{ subject: '{subject}', body: { contentType: '{body-content-type}', content: '{body-content}' }, toRecipients: '{to-recipients}', ccRecipients: '{cc-recipients}', bccRecipients: '{bcc-recipients}', importance: '{importance}' }",
445
538
  "mutates": true
446
539
  },
447
540
  {
448
541
  "name": "create-reply-draft",
449
- "summary": "Create an UNSENT reply-all draft threaded on an existing message. POST /me/messages/{id}/createReplyAll mints the draft (inherited recipients, RE: subject, quoted history) with your reply text placed above the quote, in one call. Reply-all by design - dropping recipients is a deliberate act for the human in Outlook, not a default. The draft is saved in Drafts and can be reviewed, edited, and sent from any Outlook client; the CLI still cannot send.",
542
+ "summary": "Create an UNSENT reply draft threaded on an existing message. POST /me/messages/{id}/createReplyAll mints the draft (inherited recipients, RE: subject, quoted history) with your reply text placed above the quote, in one call. Reply-all by default - dropping recipients is a deliberate act, so pass --reply-all false to reply to the sender only, which switches the action to createReply. The draft is saved in Drafts and can be reviewed, edited, and sent from any Outlook client; the CLI still cannot send.",
450
543
  "category": "mail",
451
544
  "graphMethod": "POST",
452
- "graphPathTemplate": "/me/messages/{reply-to-message-id}/createReplyAll (+ optional body-free PATCH for subject)",
545
+ "graphPathTemplate": "/me/messages/{reply-to-message-id}/createReplyAll, or /createReply when {reply-all} is false (+ optional body-free PATCH for subject)",
453
546
  "graphDocsUrl": "https://learn.microsoft.com/en-us/graph/api/message-createreplyall",
454
547
  "options": [
455
548
  {
@@ -471,18 +564,44 @@
471
564
  "name": "body-content",
472
565
  "key": "bodyContent",
473
566
  "required": true,
474
- "description": "The reply text, placed above the quoted history by Graph."
567
+ "description": "The reply text, placed above the quoted history. Plain text by default; pass --body-content-type HTML to send it as markup."
568
+ },
569
+ {
570
+ "name": "body-content-type",
571
+ "key": "bodyContentType",
572
+ "required": false,
573
+ "description": "Format of --body-content: Text (default) or HTML. Text is handed to Graph as the reply comment, which HTML-escapes it, so markup shows as literal characters. HTML instead creates the draft with an empty comment and splices your markup in at the TOP of the body — above Graph's reply separator (the `<hr>` line) and the quoted thread, so your reply leads the body content — leaving the quoted thread and its styles byte-identical. Rejected when your markup itself contains a quote boundary marker (a pasted reply chain), and when the thread is a plain-text one.",
574
+ "argumentHint": {
575
+ "kind": "magicValue",
576
+ "values": [
577
+ "Text",
578
+ "HTML"
579
+ ]
580
+ }
475
581
  },
476
582
  {
477
583
  "name": "subject",
478
584
  "key": "subject",
479
585
  "required": false,
480
586
  "description": "Optional subject override. Omit to keep the inherited \"RE: ...\" subject."
587
+ },
588
+ {
589
+ "name": "reply-all",
590
+ "key": "replyAll",
591
+ "required": false,
592
+ "description": "Who the draft replies to. Defaults to true (everyone on the thread: sender + To + Cc, via createReplyAll). Pass `false` to reply to the sender only, via createReply. Only an explicit `false` narrows the recipients - anything else keeps reply-all.",
593
+ "argumentHint": {
594
+ "kind": "magicValue",
595
+ "values": [
596
+ "true",
597
+ "false"
598
+ ]
599
+ }
481
600
  }
482
601
  ],
483
- "example": "ask-marcel-office create-reply-draft --reply-to-message-id \"AAMkAD...\" --body-content \"Confirmed for Concur, aligned with the group choice.\"",
484
- "responseShape": "The updated draft message object (or `{ ok: true }` when Graph answers 204): `{ id, subject, body, toRecipients, ccRecipients, isDraft: true, }`. The `id` is the draft - update further with update-mail-draft, or open Outlook Drafts to review and send.",
485
- "bodyTemplate": "POST { comment: '{body-content}' } then optional PATCH { subject?: '{subject}' }",
602
+ "example": "ask-marcel-office create-reply-draft --reply-to-message-id \"AAMkAD...\" --body-content \"Confirmed for Contoso, aligned with the group choice.\"",
603
+ "responseShape": "A confirmation of the write, NOT the whole message: `{ id, subject, toRecipients, ccRecipients, bccRecipients, importance, bodyPreview, isDraft, webLink, conversationId }` (only the fields Graph returned; `{ ok: true }` when Graph answers 204). The `body` is deliberately omitted — you just wrote it, and echoing a long thread's quoted history back cost ~174 KB of context per call. Read the full body with `get-mail-message --id <the returned id>` when you actually need it; `bodyPreview` is Graph's ~255-char summary, enough to confirm WHICH draft answered. The `id` is the draft refine it with `update-mail-draft`, or open Outlook Drafts to review and send. Dedup caveat: a `conversationId` `$filter` on the Drafts folder is not a reliable 'does a draft already exist on this thread' check. Reply and forward drafts do not always inherit the inbound message's `conversationId` (one thread can split across several), and Graph `$filter` on Drafts is not read-your-writes consistent, so a just-created draft can be missed. To find existing drafts, use the `find-mail-drafts` command, which scans recent drafts and matches client-side on subject and recipients; to revise a draft this session created, reuse the returned `id`.",
604
+ "bodyTemplate": "Text: POST { comment: '{body-content}' } then optional PATCH { subject?: '{subject}' }. HTML ({body-content-type}): POST { comment: '' } then ONE PATCH { body: { contentType: 'HTML', content: <'{body-content}' spliced at the top of the body, above Graph's <hr> separator and the quote> }, subject?: '{subject}' }",
486
605
  "mutates": true
487
606
  },
488
607
  {
@@ -505,6 +624,12 @@
505
624
  "required": true,
506
625
  "description": "driveItem ID of the file to convert. Returned by `list-folder-files` or `search-onedrive-files`."
507
626
  },
627
+ {
628
+ "name": "tenant-id",
629
+ "key": "tenantId",
630
+ "required": false,
631
+ "description": "Tenant GUID of a PARTNER tenant you are a guest in, when the file does not live in your own tenant. Get it from `resolve-drive-share-link`, which returns `tenantId` on any sharing URL whose file belongs to another tenant — that is the only place the tenant is knowable, because `--drive-id` and `--item-id` carry no tenant at all. Omit it for files in your own tenant (the normal case). Pass it and the request is signed with a guest token minted for that tenant instead of your home token; without it, a partner-tenant file fails with `invalidAudienceUri` no matter which of your tokens is used."
632
+ },
508
633
  {
509
634
  "name": "include-metadata",
510
635
  "key": "includeMetadata",
@@ -518,6 +643,19 @@
518
643
  ]
519
644
  }
520
645
  },
646
+ {
647
+ "name": "keep-quoted",
648
+ "key": "keepQuoted",
649
+ "required": false,
650
+ "description": "Applies to Outlook `.msg` input only. The quoted reply chain / forwarded-message block is stripped by default (it duplicates history and inflates the context budget) and replaced with a single visible marker naming this flag, so nothing is removed silently. Pass `--keep-quoted true` to render the full body. The recognized markers are the same set `convert-mail-to-markdown` uses — see `ask-marcel-office docs convert-mail-to-markdown`.",
651
+ "argumentHint": {
652
+ "kind": "magicValue",
653
+ "values": [
654
+ "true",
655
+ "false"
656
+ ]
657
+ }
658
+ },
521
659
  {
522
660
  "name": "inline-images",
523
661
  "key": "inlineImages",
@@ -561,6 +699,12 @@
561
699
  "key": "itemId",
562
700
  "required": true,
563
701
  "description": "driveItem ID of the file to convert. Returned by `list-folder-files` or `search-onedrive-files`."
702
+ },
703
+ {
704
+ "name": "tenant-id",
705
+ "key": "tenantId",
706
+ "required": false,
707
+ "description": "Tenant GUID of a PARTNER tenant you are a guest in, when the file does not live in your own tenant. Get it from `resolve-drive-share-link`, which returns `tenantId` on any sharing URL whose file belongs to another tenant — that is the only place the tenant is knowable, because `--drive-id` and `--item-id` carry no tenant at all. Omit it for files in your own tenant (the normal case). Pass it and the request is signed with a guest token minted for that tenant instead of your home token; without it, a partner-tenant file fails with `invalidAudienceUri` no matter which of your tokens is used."
564
708
  }
565
709
  ],
566
710
  "example": "ask-marcel-office download-drive-item-as-pdf --drive-id 'b!1234' --item-id '01ABC'",
@@ -589,6 +733,12 @@
589
733
  "key": "itemId",
590
734
  "required": true,
591
735
  "description": "driveItem ID of the file to download. Returned by `ask-marcel-office list-folder-files` (works on SharePoint library drives too) or `search-onedrive-files`."
736
+ },
737
+ {
738
+ "name": "tenant-id",
739
+ "key": "tenantId",
740
+ "required": false,
741
+ "description": "Tenant GUID of a PARTNER tenant you are a guest in, when the file does not live in your own tenant. Get it from `resolve-drive-share-link`, which returns `tenantId` on any sharing URL whose file belongs to another tenant — that is the only place the tenant is knowable, because `--drive-id` and `--item-id` carry no tenant at all. Omit it for files in your own tenant (the normal case). Pass it and the request is signed with a guest token minted for that tenant instead of your home token; without it, a partner-tenant file fails with `invalidAudienceUri` no matter which of your tokens is used."
592
742
  }
593
743
  ],
594
744
  "example": "ask-marcel-office download-drive-item-content --drive-id 'b!1234' --item-id '01ABC'",
@@ -673,6 +823,12 @@
673
823
  "key": "itemId",
674
824
  "required": true,
675
825
  "description": "driveItem ID of the pdf / docx / xlsx / pptx file. Returned by `list-folder-files` or `search-onedrive-files`."
826
+ },
827
+ {
828
+ "name": "tenant-id",
829
+ "key": "tenantId",
830
+ "required": false,
831
+ "description": "Tenant GUID of a PARTNER tenant you are a guest in, when the file does not live in your own tenant. Get it from `resolve-drive-share-link`, which returns `tenantId` on any sharing URL whose file belongs to another tenant — that is the only place the tenant is knowable, because `--drive-id` and `--item-id` carry no tenant at all. Omit it for files in your own tenant (the normal case). Pass it and the request is signed with a guest token minted for that tenant instead of your home token; without it, a partner-tenant file fails with `invalidAudienceUri` no matter which of your tokens is used."
676
832
  }
677
833
  ],
678
834
  "example": "ask-marcel-office extract-drive-item-images --drive-id 'b!1234' --item-id '01ABC' --output-dir ./deck-images",
@@ -681,7 +837,7 @@
681
837
  },
682
838
  {
683
839
  "name": "extract-local-file-images",
684
- "summary": "Extract the embedded images from a file ON DISK — the local sibling of `extract-drive-item-images`, and like `convert-local-file` it never calls Microsoft Graph (works offline, no login). Same per-extension dispatch: docx / xlsx / pptx (and their macro-enabled / template variants) have their OOXML media parts read directly (png/jpg/gif/bmp/tiff/webp/svg — full-resolution originals, including images on hidden slides); a pdf is walked page by page via unpdf with each painted image re-encoded as PNG. Two flows only this command completes: a Graph-rendered PDF saved locally (legacy `.ppt` → `download-drive-item-as-pdf` with the global output-path flag → this command pulls the slide images for OCR), and Office files unpacked from a local archive. Pair with the global output-dir flag to write every image to a folder; otherwise the bytes ride back base64-encoded. Any other extension returns a 415 naming the local ways out.",
840
+ "summary": "Extract the embedded images from a file ON DISK — the local sibling of `extract-drive-item-images`, and like `convert-local-file-to-markdown` it never calls Microsoft Graph (works offline, no login). Same per-extension dispatch: docx / xlsx / pptx (and their macro-enabled / template variants) have their OOXML media parts read directly (png/jpg/gif/bmp/tiff/webp/svg — full-resolution originals, including images on hidden slides); a pdf is walked page by page via unpdf with each painted image re-encoded as PNG. Two flows only this command completes: a Graph-rendered PDF saved locally (legacy `.ppt` → `download-drive-item-as-pdf` with the global output-path flag → this command pulls the slide images for OCR), and Office files unpacked from a local archive. Pair with the global output-dir flag to write every image to a folder; otherwise the bytes ride back base64-encoded. Any other extension returns a 415 naming the local ways out.",
685
841
  "category": "meta",
686
842
  "graphMethod": "GET",
687
843
  "graphPathTemplate": "(local) reads {path} from the local filesystem; not a Graph endpoint",
@@ -800,9 +956,33 @@
800
956
  ],
801
957
  "example": "ask-marcel-office find-chats-with-user --name 'Alex Kim'",
802
958
  "responseShape": "`{ name, matches: [{ chatId, title, chatType, threadType, memberCount, lastMessageAt?, matchedMembers: [{ mri, displayName, email, userSubType }] }], matchCount, pagesFetched, chatsScanned, chatsHydrated, unresolvedMemberCount, hasMore, nextContinuationToken?, hint? }`. `matchedMembers` always carries the matching entries' identifying fields — pass `chatId` into `list-teams-chat-history` to read message bodies. `chatsHydrated` counts the per-chat members lookups spent resolving bare cross-tenant members in direct (1:1) chats. `unresolvedMemberCount` is how many cross-tenant members are still unresolved by name (bare members in group/meeting chats, which are not deep-probed, plus any 1:1 hydration that errored); when `matchCount` is 0 and this is non-zero, a `hint` is present explaining the likely cause and the object-id / read-by-chat-id remedy — so an empty result is never silently confident. `hasMore: true` means `--max-pages` was hit before exhausting the chat list; chain with the existing `--continuation-token` flag on `list-teams-chats-with-messages` if you need to scan further (this command does not advertise a `--continuation-token` because resuming a partial search is rare; users either widen `--max-pages` or refine `--name`).",
803
- "needsSubstrateToken": true,
959
+ "needsSubstrateToken": "chatsvcagg",
804
960
  "stability": "experimental"
805
961
  },
962
+ {
963
+ "name": "find-mail-drafts",
964
+ "summary": "Find existing drafts on a mail thread WITHOUT trusting a conversationId $filter. Reply and forward drafts do not always inherit the inbound message conversationId (a thread can split across several), and Graph $filter on the Drafts folder is not read-your-writes consistent, so filtering Drafts by conversationId misses drafts. This command instead scans the 50 most recently modified drafts and matches them client-side on a normalized subject (stripping RE:/FW: and localized reply/forward prefixes) plus, optionally, a shared recipient. Use it before create-reply-draft to avoid creating a duplicate: if a match comes back, revise it with update-mail-draft instead of making a new one. Read-only.",
965
+ "category": "mail",
966
+ "graphMethod": "GET",
967
+ "graphPathTemplate": "/me/mailFolders/drafts/messages?$top=50&$orderby=lastModifiedDateTime desc&$select=id,subject,toRecipients,ccRecipients,conversationId,lastModifiedDateTime,webLink (a read-only scan of the 50 most recently modified drafts; each is matched CLIENT-SIDE on a normalized {subject} and, when given, a shared {to-recipients} address, so neither value is sent to Graph)",
968
+ "graphDocsUrl": "https://learn.microsoft.com/en-us/graph/api/mailfolder-list-messages",
969
+ "options": [
970
+ {
971
+ "name": "subject",
972
+ "key": "subject",
973
+ "required": true,
974
+ "description": "The thread subject to match. Reply and forward prefixes (RE:, FW:, and localized variants) are stripped on both sides before comparing, so \"Contoso Q3\" matches a draft titled \"RE: Contoso Q3\"."
975
+ },
976
+ {
977
+ "name": "to-recipients",
978
+ "key": "toRecipients",
979
+ "required": false,
980
+ "description": "Optional comma-separated recipient addresses. When given, a draft matches only if it shares at least one To or Cc address with this list, narrowing subject-only collisions."
981
+ }
982
+ ],
983
+ "example": "ask-marcel-office find-mail-drafts --subject \"Contoso Q3 budget\" --to-recipients \"kim@example.com\"",
984
+ "responseShape": "`{ matches: message[], conversationIds: string[], scanned: number, scanLimit: number }`. `matches` are the drafts whose normalized subject (RE:/FW:/localized prefixes stripped) equals yours, each carrying `{ id, subject, toRecipients, ccRecipients, conversationId, lastModifiedDateTime, webLink }`; pass a match `id` to update-mail-draft to revise it instead of creating a duplicate. `conversationIds` is the de-duplicated union of conversationIds across the matches (a thread can span several). `scanned` is how many drafts were examined and `scanLimit` the cap (50): when `scanned` equals `scanLimit`, older drafts may exist beyond the window."
985
+ },
806
986
  {
807
987
  "name": "get-calendar-event",
808
988
  "summary": "Fetch a single calendar event by ID from the signed-in user’s default calendar. Pass `--select` to project only the fields you need (the full event body can be large with HTML body and attendee lists).",
@@ -1017,6 +1197,12 @@
1017
1197
  "required": true,
1018
1198
  "description": "driveItem ID. Returned by `list-folder-files`, `search-onedrive-files`, or `get-drive-root-item`."
1019
1199
  },
1200
+ {
1201
+ "name": "tenant-id",
1202
+ "key": "tenantId",
1203
+ "required": false,
1204
+ "description": "Tenant GUID of a PARTNER tenant you are a guest in, when the file does not live in your own tenant. Get it from `resolve-drive-share-link`, which returns `tenantId` on any sharing URL whose file belongs to another tenant — that is the only place the tenant is knowable, because `--drive-id` and `--item-id` carry no tenant at all. Omit it for files in your own tenant (the normal case). Pass it and the request is signed with a guest token minted for that tenant instead of your home token; without it, a partner-tenant file fails with `invalidAudienceUri` no matter which of your tokens is used."
1205
+ },
1020
1206
  {
1021
1207
  "name": "select",
1022
1208
  "key": "select",
@@ -1160,6 +1346,12 @@
1160
1346
  "key": "expand",
1161
1347
  "required": false,
1162
1348
  "description": "OData $expand: navigation properties to include inline (e.g. `attachments`). Increases response size; use sparingly."
1349
+ },
1350
+ {
1351
+ "name": "tenant-id",
1352
+ "key": "tenantId",
1353
+ "required": false,
1354
+ "description": "Tenant GUID of a PARTNER tenant you are a guest in, when the file does not live in your own tenant. Get it from `resolve-drive-share-link`, which returns `tenantId` on any sharing URL whose file belongs to another tenant — that is the only place the tenant is knowable, because `--drive-id` and `--item-id` carry no tenant at all. Omit it for files in your own tenant (the normal case). Pass it and the request is signed with a guest token minted for that tenant instead of your home token; without it, a partner-tenant file fails with `invalidAudienceUri` no matter which of your tokens is used."
1163
1355
  }
1164
1356
  ],
1165
1357
  "example": "ask-marcel-office get-drive-item-list-item --drive-id 'b!1234' --item-id '01ABC'",
@@ -1509,7 +1701,7 @@
1509
1701
  },
1510
1702
  {
1511
1703
  "name": "get-mail-message",
1512
- "summary": "Get a single Outlook message by ID. The CLI ships a slim default `--select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,hasAttachments,isRead,importance,bodyPreview` so an LLM caller doesn't pull a 41 KB resource just to read a subject line. Pass `--select id,subject,body` (or any other comma-separated field list) to override; for the raw RFC-822 source use `get-mail-message-mime` instead.",
1704
+ "summary": "Get a single Outlook message by ID. The CLI ships a slim default `--select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,hasAttachments,isRead,importance,bodyPreview,conversationId` (`conversationId` gives you the thread, e.g. for `list-conversation-messages`) so an LLM caller doesn't pull a 41 KB resource just to read a subject line. Pass `--select id,subject,body` (or any other comma-separated field list) to override; for the raw RFC-822 source use `get-mail-message-mime` instead.",
1513
1705
  "category": "mail",
1514
1706
  "graphMethod": "GET",
1515
1707
  "graphPathTemplate": "/me/messages/{message-id}",
@@ -1602,6 +1794,34 @@
1602
1794
  "example": "ask-marcel-office get-mail-rule --message-rule-id 'AQAAANC...'",
1603
1795
  "responseShape": "single Microsoft Graph `messageRule` resource"
1604
1796
  },
1797
+ {
1798
+ "name": "get-mail-signature",
1799
+ "summary": "Read your own email signature as HTML, lifted from a message you already sent. Graph-created drafts carry NO signature (create-mail-draft, create-reply-draft, and create-forward-draft all produce unsigned bodies), so this is where you get one: take the `text` this returns, append it to your reply text, and hand the result to `update-mail-draft` in comment mode (HTML body-content-type) to place it above the quoted history. Scans your last 10 sent messages newest-first and returns the first `<div id=\"Signature\">` block it finds, stopping there, with any logo the block references embedded as a base64 data: URI so the HTML renders on its own. Read-only. NOTE: the marker is written by Outlook on the web and new Outlook; mail composed in Outlook desktop does not carry it, so pin a webmail-sent message with --message-id if the scan finds nothing.",
1800
+ "category": "mail",
1801
+ "graphMethod": "GET",
1802
+ "graphPathTemplate": "/me/mailFolders/sentitems/messages (scan, skipped when {message-id} is given) then /me/messages/{message-id}?$select=body,sentDateTime,hasAttachments (+ /attachments per referenced logo)",
1803
+ "graphDocsUrl": "https://learn.microsoft.com/en-us/graph/api/message-get",
1804
+ "options": [
1805
+ {
1806
+ "name": "message-id",
1807
+ "key": "messageId",
1808
+ "required": false,
1809
+ "aliases": [
1810
+ {
1811
+ "name": "id",
1812
+ "key": "id"
1813
+ }
1814
+ ],
1815
+ "description": "Read the signature from THIS message instead of scanning the sent folder. Use when the scan finds nothing (the message was composed in Outlook desktop) or to pin a specific signature. Source from list-mail-folder-messages --mail-folder-id sentitems. Accepts `--id` as an alias.",
1816
+ "argumentHint": {
1817
+ "kind": "idOrName"
1818
+ }
1819
+ }
1820
+ ],
1821
+ "example": "ask-marcel-office get-mail-signature",
1822
+ "responseShape": "`{ contentType: \"text/html\", size, text, sourceMessageId, sentDateTime?, inlinedImages, note? }`. `text` is the signature block itself (the `<div id=\"Signature\">` element, not the whole body), ready to append to a reply. `inlinedImages` counts the logos embedded as data: URIs; any image too large (> 2 MB) or unfetchable keeps its raw `cid:` reference and is named in `note` — no placeholder is substituted, so the reference stays resolvable via get-mail-attachment. `--output-path` writes the HTML to a file.",
1823
+ "producesBytes": true
1824
+ },
1605
1825
  {
1606
1826
  "name": "get-mailbox-settings",
1607
1827
  "summary": "Get the signed-in user's Outlook mailbox settings (timezone, working hours, automatic replies). Note: Graph silently ignores `$select` / `$expand` on this endpoint, so the CLI does NOT expose them — the full payload (including the auto-reply HTML body) is always returned. Slim client-side if you only need a subset.",
@@ -2437,7 +2657,7 @@
2437
2657
  ],
2438
2658
  "example": "ask-marcel-office get-teams-chat-message --chat-id '19:abc...@unq.gbl.spaces' --message-id '1700000000000'",
2439
2659
  "responseShape": "single Teams chat message — `id`, `from`, `imDisplayName`, `content`, `contentType`, `composeTime`, `originalArrivalTime`, etc. **Microsoft-internal schema — fields may change without notice.**",
2440
- "needsSubstrateToken": true,
2660
+ "needsSubstrateToken": "chatsvcagg",
2441
2661
  "stability": "experimental"
2442
2662
  },
2443
2663
  {
@@ -2492,6 +2712,43 @@
2492
2712
  "example": "ask-marcel-office get-todo-task --todo-task-list-id 'AAMkAGI...' --todo-task-id 'AAMkABC...'",
2493
2713
  "responseShape": "single Microsoft Graph `todoTask` resource (slimmed by `--select` when supplied)"
2494
2714
  },
2715
+ {
2716
+ "name": "get-user",
2717
+ "summary": "Look up a directory user. Pass an Azure AD id, UPN, or email as --user-id and get that user's FULL profile (displayName, mail, jobTitle, department, officeLocation, phones) via GET /users/{id} on the elevated M365 token — preflight it with `ask-marcel-office scopes-check` (no Graph call) and run `ask-marcel-office login` first if it is cold. An email resolves even when it is the user's `mail` rather than their sign-in UPN: guest / B2B users carry a `#EXT#` UPN whose local part is NOT their email address, so when the direct lookup 404s the command falls back to `GET /users?$filter=mail eq '<email>'` and returns the single match. Only THIS tenant's directory is queried: a person's home-tenant object id (e.g. a cross-tenant Teams `8:orgid:<home-id>` participant, whose id lives in their own tenant) and any email that is not their `mail`/UPN here are unresolvable by design — reach an external person via their LOCAL guest projection (by name, or by their real `mail`), never by their home id. Pass a NAME instead and it searches your relevant-people graph (GET /me/people) and returns candidate matches (id, displayName, mail, jobTitle, department) so you can pick the right person and re-query. Re-query by the candidate's `id` when it is a directory GUID; an EXTERNAL contact's candidate carries a base64-ish People-API id instead, which nothing can resolve — re-query those by the candidate's `mail` (the CLI rejects an opaque contact id with that remedy rather than returning empty matches). Name search covers colleagues in your people graph, not the whole tenant directory; use `microsoft-search-query` for a broader tenant-wide person search. Without `--select`, the profile ships a default projection of id, displayName, userPrincipalName, mail, jobTitle, department, officeLocation, businessPhones, mobilePhone.",
2718
+ "category": "user",
2719
+ "graphMethod": "GET",
2720
+ "graphPathTemplate": "/users/{user-id} (id / UPN; an email that misses falls back to /users?$filter=mail eq) OR /me/people?$search=\"{user-id}\" (a bare name)",
2721
+ "graphDocsUrl": "https://learn.microsoft.com/en-us/graph/api/user-get",
2722
+ "options": [
2723
+ {
2724
+ "name": "user-id",
2725
+ "key": "userId",
2726
+ "required": true,
2727
+ "aliases": [
2728
+ {
2729
+ "name": "id",
2730
+ "key": "id"
2731
+ }
2732
+ ],
2733
+ "description": "Azure AD user ID, UPN, or email (returns the full profile via the elevated token), OR a display name (returns relevant-people candidates on the basic token). Discover ids via `list-relevant-people` or `microsoft-search-query`."
2734
+ },
2735
+ {
2736
+ "name": "select",
2737
+ "key": "select",
2738
+ "required": false,
2739
+ "description": "OData $select: comma-separated list of fields to include in each item (e.g. `id,subject,from`). May shrink payloads dramatically — Graph honors $select on most endpoints, but some collections (notably `/me/mailboxSettings`, `/me/outlook/masterCategories`, `/me/mailFolders/inbox/messageRules`) silently ignore it and always return the full resource. Bogus field names are silently dropped by Graph — if a field you asked for is missing from the response, double-check the spelling against the documented `responseShape`."
2740
+ },
2741
+ {
2742
+ "name": "expand",
2743
+ "key": "expand",
2744
+ "required": false,
2745
+ "description": "OData $expand: navigation properties to include inline (e.g. `attachments`). Increases response size; use sparingly."
2746
+ }
2747
+ ],
2748
+ "example": "ask-marcel-office get-user --user-id 'alice@contoso.com' --select 'id,displayName,mail,jobTitle,department'",
2749
+ "responseShape": "For an id / UPN / email: a single Microsoft Graph `user` resource projected to the default `$select` (id, displayName, userPrincipalName, mail, jobTitle, department, officeLocation, businessPhones, mobilePhone) unless `--select` overrides it; honours `--expand`. An email is tried against the sign-in UPN first, then falls back to the `mail` attribute (`$filter=mail eq`) so guest/B2B users resolve; an email that matches nobody returns the direct-path 404. For a name: `{ query, matches: [{ id, displayName, mail, jobTitle, department }] }` from the People API (empty `matches` when nobody in your relevant-people graph matches; re-query by a chosen GUID `id`, or by `mail` when the candidate is an external contact with a base64-ish People-API id — passing that id back is rejected with the same remedy). The id path needs the elevated M365 token and fail-fasts with `secondary_token_unavailable` when it is cold — preflight with `ask-marcel-office scopes-check` and run `ask-marcel-office login` to re-capture it.",
2750
+ "needsElevatedToken": true
2751
+ },
2495
2752
  {
2496
2753
  "name": "get-user-manager",
2497
2754
  "summary": "Return a specific user's manager (a single `user` resource). When the user has no manager set in the directory, Graph returns 404 `Request_ResourceNotFound`; this command maps that one specific 404 to `{ ok: true, data: { manager: null, note: '...' } }` (same shape as `get-my-manager`) so an LLM can distinguish 'no manager' from 'unknown user' with a single discriminator across both commands. Use `--select` to slim the response.",
@@ -3287,6 +3544,12 @@
3287
3544
  "key": "expand",
3288
3545
  "required": false,
3289
3546
  "description": "OData $expand: navigation properties to include inline (e.g. `attachments`). Increases response size; use sparingly."
3547
+ },
3548
+ {
3549
+ "name": "tenant-id",
3550
+ "key": "tenantId",
3551
+ "required": false,
3552
+ "description": "Tenant GUID of a PARTNER tenant you are a guest in, when the file does not live in your own tenant. Get it from `resolve-drive-share-link`, which returns `tenantId` on any sharing URL whose file belongs to another tenant — that is the only place the tenant is knowable, because `--drive-id` and `--item-id` carry no tenant at all. Omit it for files in your own tenant (the normal case). Pass it and the request is signed with a guest token minted for that tenant instead of your home token; without it, a partner-tenant file fails with `invalidAudienceUri` no matter which of your tokens is used."
3290
3553
  }
3291
3554
  ],
3292
3555
  "example": "ask-marcel-office list-drive-item-permissions --drive-id 'b!1234' --item-id '01ABC'",
@@ -3342,6 +3605,12 @@
3342
3605
  "key": "expand",
3343
3606
  "required": false,
3344
3607
  "description": "OData $expand: navigation properties to include inline (e.g. `attachments`). Increases response size; use sparingly."
3608
+ },
3609
+ {
3610
+ "name": "tenant-id",
3611
+ "key": "tenantId",
3612
+ "required": false,
3613
+ "description": "Tenant GUID of a PARTNER tenant you are a guest in, when the file does not live in your own tenant. Get it from `resolve-drive-share-link`, which returns `tenantId` on any sharing URL whose file belongs to another tenant — that is the only place the tenant is knowable, because `--drive-id` and `--item-id` carry no tenant at all. Omit it for files in your own tenant (the normal case). Pass it and the request is signed with a guest token minted for that tenant instead of your home token; without it, a partner-tenant file fails with `invalidAudienceUri` no matter which of your tokens is used."
3345
3614
  }
3346
3615
  ],
3347
3616
  "example": "ask-marcel-office list-drive-item-thumbnails --drive-id 'b!1234' --item-id '01ABC'",
@@ -3397,6 +3666,12 @@
3397
3666
  "key": "expand",
3398
3667
  "required": false,
3399
3668
  "description": "OData $expand: navigation properties to include inline (e.g. `attachments`). Increases response size; use sparingly."
3669
+ },
3670
+ {
3671
+ "name": "tenant-id",
3672
+ "key": "tenantId",
3673
+ "required": false,
3674
+ "description": "Tenant GUID of a PARTNER tenant you are a guest in, when the file does not live in your own tenant. Get it from `resolve-drive-share-link`, which returns `tenantId` on any sharing URL whose file belongs to another tenant — that is the only place the tenant is knowable, because `--drive-id` and `--item-id` carry no tenant at all. Omit it for files in your own tenant (the normal case). Pass it and the request is signed with a guest token minted for that tenant instead of your home token; without it, a partner-tenant file fails with `invalidAudienceUri` no matter which of your tokens is used."
3400
3675
  }
3401
3676
  ],
3402
3677
  "example": "ask-marcel-office list-drive-item-versions --drive-id 'b!1234' --item-id '01ABC'",
@@ -3965,6 +4240,12 @@
3965
4240
  "key": "expand",
3966
4241
  "required": false,
3967
4242
  "description": "OData $expand: navigation properties to include inline (e.g. `attachments`). Increases response size; use sparingly."
4243
+ },
4244
+ {
4245
+ "name": "tenant-id",
4246
+ "key": "tenantId",
4247
+ "required": false,
4248
+ "description": "Tenant GUID of a PARTNER tenant you are a guest in, when the file does not live in your own tenant. Get it from `resolve-drive-share-link`, which returns `tenantId` on any sharing URL whose file belongs to another tenant — that is the only place the tenant is knowable, because `--drive-id` and `--item-id` carry no tenant at all. Omit it for files in your own tenant (the normal case). Pass it and the request is signed with a guest token minted for that tenant instead of your home token; without it, a partner-tenant file fails with `invalidAudienceUri` no matter which of your tokens is used."
3968
4249
  }
3969
4250
  ],
3970
4251
  "example": "ask-marcel-office list-folder-files --drive-id 'b!1234' --item-id '01ROOT'",
@@ -4717,7 +4998,7 @@
4717
4998
  "key": "id"
4718
4999
  }
4719
5000
  ],
4720
- "description": "mailFolder ID. Returned by `ask-marcel-office list-mail-folders`. Well-known names also work, e.g. `inbox`, `sentitems`, `drafts`."
5001
+ "description": "mailFolder ID. Returned by `ask-marcel-office list-mail-folders`. Well-known names also work, e.g. `inbox`, `sentitems`, `drafts`. When listing `drafts`, a `conversationId` `$filter` is not a reliable check for whether a draft already exists on a thread: reply and forward drafts can split across several conversationIds, and `$filter` on Drafts is not read-your-writes consistent. Match client-side on subject and recipients instead, or use the `find-mail-drafts` command, which does exactly that."
4721
5002
  },
4722
5003
  {
4723
5004
  "name": "top",
@@ -4884,7 +5165,7 @@
4884
5165
  },
4885
5166
  {
4886
5167
  "name": "list-mail-messages",
4887
- "summary": "List the most recent messages from across the signed-in user's entire Outlook mailbox (every folder including Sent, Archive, Junk; default sort `receivedDateTime` desc). The CLI ships a slim default `--select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,hasAttachments,isRead,importance,bodyPreview` so a page of 25 messages stays ~30-60 KB instead of ~1 MB. Pass `--select id,subject,body` (or any other comma-separated field list) to override. Use `list-mail-folder-messages` to scope to a single folder such as Inbox.",
5168
+ "summary": "List the most recent messages from across the signed-in user's entire Outlook mailbox (every folder including Sent, Archive, Junk; default sort `receivedDateTime` desc). The CLI ships a slim default `--select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,hasAttachments,isRead,importance,bodyPreview,conversationId` (`conversationId` groups messages into a thread and can be handed to `list-conversation-messages`) so a page of 25 messages stays ~30-60 KB instead of ~1 MB. Pass `--select id,subject,body` (or any other comma-separated field list) to override. Use `list-mail-folder-messages` to scope to a single folder such as Inbox.",
4888
5169
  "category": "mail",
4889
5170
  "graphMethod": "GET",
4890
5171
  "graphPathTemplate": "/me/messages",
@@ -6895,7 +7176,7 @@
6895
7176
  ],
6896
7177
  "example": "ask-marcel-office list-teams-chat-history --chat-id '19:abc...@unq.gbl.spaces' --max-pages 5",
6897
7178
  "responseShape": "`{ messages: [...], hasMore: boolean, pagesFetched: number, nextSyncState?: string, projection: 'slim' | 'full' }`. Slim projection (default) emits `{ id, sequenceId, composetime, originalarrivaltime, messagetype, from, imdisplayname, content }` per message, with `truncated: true` + `originalContentChars` on entries whose `content` exceeded `--max-content-chars` (default 4096). With `--full true`, returns the raw IC3 substrate shape: `id`, `sequenceId` (monotonic per-chat counter), `composetime`, `originalarrivaltime`, `messagetype`, `content`, `from`, `imdisplayname`, `properties.subject`, etc. **`hasMore: true`** means the safety cap was hit and there is older history beyond what was returned — chain a follow-up call with `--sync-state $(jq -r .data.nextSyncState <prev>)` to continue. **`hasMore: false`** means the chat's earliest message was reached. **Microsoft-internal schema — fields may change without notice.**",
6898
- "needsSubstrateToken": true,
7179
+ "needsSubstrateToken": "ic3",
6899
7180
  "stability": "experimental"
6900
7181
  },
6901
7182
  {
@@ -6921,7 +7202,7 @@
6921
7202
  ],
6922
7203
  "example": "ask-marcel-office list-teams-chat-messages --chat-id '19:abc...@unq.gbl.spaces'",
6923
7204
  "responseShape": "Substrate envelope: `{ messages: [...], messageToken: string }`. Returns up to the 200 most recent messages per chat — older history is NOT reachable via this endpoint. Each message has `id`, `from`, `imDisplayName`, `content`, `contentType`, `composeTime`, `originalArrivalTime`, `sequenceId`, etc. `messageToken` is returned for forward compatibility but is currently a static snapshot identifier (server ignores it as a pagination cursor). **Microsoft-internal schema — fields may change without notice.** For history older than the 200 most recent, use `list-teams-chat-history` (rides the IC3 substrate with a working syncState cursor).",
6924
- "needsSubstrateToken": true,
7205
+ "needsSubstrateToken": "chatsvcagg",
6925
7206
  "stability": "experimental"
6926
7207
  },
6927
7208
  {
@@ -6947,7 +7228,7 @@
6947
7228
  ],
6948
7229
  "example": "ask-marcel-office list-teams-chats-with-messages --page-size 100",
6949
7230
  "responseShape": "`{ chats: [...], continuationToken?: string, hasMoreData?: boolean }`. Each chat carries `id`, `title`, `chatType`, `threadType`, `members[]` (with each member's `mri`, `displayName`, `email`), `createdAt`, AND `lastMessage` (the most recent message body inlined — `content`, `from`, `composeTime`, `imDisplayName`, etc.). When `hasMoreData: true`, chain a follow-up call with `--continuation-token \"$(jq -r .data.continuationToken <prev>)\"`. **Microsoft-internal schema — fields may change without notice; treat the response as semi-structured.**",
6950
- "needsSubstrateToken": true,
7231
+ "needsSubstrateToken": "chatsvcagg",
6951
7232
  "stability": "experimental"
6952
7233
  },
6953
7234
  {
@@ -7308,7 +7589,7 @@
7308
7589
  },
7309
7590
  {
7310
7591
  "name": "next-page",
7311
- "summary": "Fetch the next page of a paginated Graph response. Pass the cursor the previous command emitted — in text mode that is the `next: <url>` value in the `---` footer; in JSON mode it is the top-level `nextLink` field. Never reach into `data[\"@odata.nextLink\"]`; the CLI strips that and surfaces it as a first-class envelope/footer field. Automatically signs `/me/chats` and `/chats/...` cursors with the M365ChatClient elevated token to match the chat-metadata commands.",
7592
+ "summary": "Fetch the next page of a paginated Graph response. Pass the cursor the previous command emitted — in text mode the `---` footer prints the whole ready-to-run command (`next: ask-marcel-office next-page --url '<url>'`), so copy the line as-is (the URL is single-quoted because it contains `$`); in JSON mode use the top-level `nextLink` field. Never reach into `data[\"@odata.nextLink\"]`; the CLI strips that and surfaces it as a first-class envelope/footer field. Automatically signs `/me/chats` and `/chats/...` cursors with the M365ChatClient elevated token to match the chat-metadata commands.",
7312
7593
  "category": "meta",
7313
7594
  "graphMethod": "GET",
7314
7595
  "graphPathTemplate": "{url}",
@@ -7329,7 +7610,7 @@
7329
7610
  },
7330
7611
  {
7331
7612
  "name": "read-mail-attachment",
7332
- "summary": "Read an Outlook mail attachment whatever it is — one command that auto-routes by file type, preferring the content-type when the filename extension is misleading (a real `.jpg` that is actually a spreadsheet still converts), so a caller never has to choose between the convert-mail-attachment-* siblings. A `.zip` fileAttachment is unpacked and every entry converted (mirrors `convert-mail-attachment-zip`, returning the `{ count, files }` envelope; legacy GBK/CP437 names decoded). Any other attachment — docx/xlsx/pptx/odt/ods/odp + macro/template variants → markdown, csv → table, pdf → text layer (with `pageCount`), legacy .xls/.doc extracted, an inner Outlook .msg rendered recursively, plain text passed through, referenceAttachment resolved via `/shares`, and itemAttachment (embedded mail/event/contact) rendered — goes through the same dispatch as `convert-mail-attachment-to-markdown` (returning its `{ contentType, size, text }` envelope). Images, scanned/image-only PDFs, and legacy .ppt return an actionable 415 pointing at `convert-mail-attachment-to-pdf` + a vision model or `get-mail-attachment` for the raw bytes. Pass `--include-metadata true` to append Office side-channel metadata. Use the explicit `convert-mail-attachment-to-markdown` / `-to-pdf` / `-zip` siblings only when you need to force a specific output format.",
7613
+ "summary": "Read an Outlook mail attachment whatever it is — one command that auto-routes by file type, preferring the content-type when the filename extension is misleading (a real `.jpg` that is actually a spreadsheet still converts), so a caller never has to choose between the convert-mail-attachment-* siblings. A `.zip` fileAttachment is unpacked and every entry converted (mirrors `convert-mail-attachment-zip-to-markdown`, returning the `{ count, files }` envelope; legacy GBK/CP437 names decoded). Any other attachment — docx/xlsx/pptx/odt/ods/odp + macro/template variants → markdown, csv → table, pdf → text layer (with `pageCount`), legacy .xls/.doc extracted, an inner Outlook .msg rendered recursively (quoted chain stripped unless `--keep-quoted true`), plain text passed through, referenceAttachment resolved via `/shares`, and itemAttachment (embedded mail/event/contact) rendered — goes through the same dispatch as `convert-mail-attachment-to-markdown` (returning its `{ contentType, size, text }` envelope). Images, scanned/image-only PDFs, and legacy .ppt return an actionable 415 pointing at `convert-mail-attachment-to-pdf` + a vision model or `get-mail-attachment` for the raw bytes. Pass `--include-metadata true` to append Office side-channel metadata. Use the explicit `convert-mail-attachment-to-markdown` / `-to-pdf` / `-zip` siblings only when you need to force a specific output format.",
7333
7614
  "category": "mail",
7334
7615
  "graphMethod": "GET",
7335
7616
  "graphPathTemplate": "/me/messages/{message-id}/attachments/{attachment-id}",
@@ -7359,10 +7640,23 @@
7359
7640
  "false"
7360
7641
  ]
7361
7642
  }
7643
+ },
7644
+ {
7645
+ "name": "keep-quoted",
7646
+ "key": "keepQuoted",
7647
+ "required": false,
7648
+ "description": "Applies to Outlook `.msg` input only. The quoted reply chain / forwarded-message block is stripped by default (it duplicates history and inflates the context budget) and replaced with a single visible marker naming this flag, so nothing is removed silently. Pass `--keep-quoted true` to render the full body. The recognized markers are the same set `convert-mail-to-markdown` uses — see `ask-marcel-office docs convert-mail-to-markdown`.",
7649
+ "argumentHint": {
7650
+ "kind": "magicValue",
7651
+ "values": [
7652
+ "true",
7653
+ "false"
7654
+ ]
7655
+ }
7362
7656
  }
7363
7657
  ],
7364
7658
  "example": "ask-marcel-office read-mail-attachment --message-id 'AAMkAD...' --attachment-id 'AAMkAD...attach1'",
7365
- "responseShape": "Polymorphic by attachment content-type. A zip → `{ count, files: [{ path, contentType, size, text } | { path, note }], truncated? }` (the convert-mail-attachment-zip shape). Everything else → `{ contentType: \"text/markdown\" | \"text/plain\", size, text, pageCount? }` (the convert-mail-attachment-to-markdown shape; `pageCount` present for PDF sources). Unsupported types (image / scanned PDF / legacy .ppt) return an api_error (415/400) naming the right next command.",
7659
+ "responseShape": "Polymorphic by attachment content-type. A zip → `{ count, files: [{ path, contentType, size, text } | { path, note }], truncated? }` (the convert-mail-attachment-zip-to-markdown shape). Everything else → `{ contentType: \"text/markdown\" | \"text/plain\", size, text, pageCount? }` (the convert-mail-attachment-to-markdown shape; `pageCount` present for PDF sources). Unsupported types (image / scanned PDF / legacy .ppt) return an api_error (415/400) naming the right next command.",
7366
7660
  "producesBytes": true
7367
7661
  },
7368
7662
  {
@@ -7385,10 +7679,10 @@
7385
7679
  },
7386
7680
  {
7387
7681
  "name": "resolve-drive-share-link",
7388
- "summary": "Encode a OneDrive / SharePoint sharing URL into the Graph `/shares/{token}` share token (`u!<base64url>` per [shares-get](https://learn.microsoft.com/en-us/graph/api/shares-get)). Pure transformation — no Graph call. Pipe the returned `graphPath` (`/shares/{token}/driveItem`) into a sibling lookup (`get-drive-item`, `download-drive-item-content`, `convert-mail-attachment-to-pdf`, etc.) once the file has been resolved to a `driveItem`. Accepts any `*.sharepoint.com` URL (tenant + `*-my.sharepoint.com` personal OneDrive) and Microsoft's short-link host `1drv.ms`.",
7682
+ "summary": "Resolve a OneDrive / SharePoint sharing URL (a \"Copy link\" address someone sent you) to the file it points at, returning `driveId` + `itemId` ready to feed `get-drive-item`, `download-drive-item-content`, `convert-drive-item-*`, `extract-drive-item-images`, and the rest of the `*-drive-item` family. It encodes the URL to the Graph `/shares/{token}` share token (`u!<base64url>` per [shares-get](https://learn.microsoft.com/en-us/graph/api/shares-get)) and fetches `/shares/{token}/driveItem` in ONE call (basic token, `Files.Read.All`) a raw sharing URL carries no ids, so this is the entry point into the drive-item family from a shared link. Accepts any `*.sharepoint.com` URL (tenant + `*-my.sharepoint.com` personal OneDrive) and Microsoft's short-link host `1drv.ms`. **Cross-tenant links work too**: when the URL belongs to a tenant you are only a GUEST in, your home token cannot read its SharePoint (Graph answers `invalidAudienceUri` — it cannot mint a SharePoint token for a foreign tenant), so this command identifies the owning tenant from the URL host and retries with a guest token automatically, then returns that tenant as `tenantId` — see `responseShape` for how to pass it on to the follow-up calls.",
7389
7683
  "category": "drive",
7390
7684
  "graphMethod": "GET",
7391
- "graphPathTemplate": "{url}",
7685
+ "graphPathTemplate": "/shares/(u!<base64url> of {url})/driveItem",
7392
7686
  "graphDocsUrl": "https://learn.microsoft.com/en-us/graph/api/shares-get",
7393
7687
  "options": [
7394
7688
  {
@@ -7399,7 +7693,7 @@
7399
7693
  }
7400
7694
  ],
7401
7695
  "example": "ask-marcel-office resolve-drive-share-link --url 'https://contoso.sharepoint.com/:b:/s/team/EaB1cD2eF...?e=abc'",
7402
- "responseShape": "`{ shareToken: string, graphPath: string, originalUrl: string }`. `shareToken` is the `u!<base64url>` form. `graphPath` is the ready-to-use `/shares/{token}/driveItem` URL — pass it to `ask-marcel-office next-page --url <link>` for a one-shot driveItem fetch, or feed the `shareToken` into any future `/shares/{token}/...` endpoint. `originalUrl` is echoed back for round-trip confirmation."
7696
+ "responseShape": "`{ driveId, itemId, name, webUrl, size, lastModifiedDateTime, shareToken, tenantId? }`. `driveId` (from the item's `parentReference`) + `itemId` feed every `*-drive-item` command directly — no second call. `shareToken` is the `u!<base64url>` form, kept for reuse against other `/shares/{token}/...` endpoints. **`tenantId` is present ONLY when the link belongs to a partner tenant you are a guest in** when it is there, pass it as `--tenant-id` to every follow-up `*-drive-item` call (`download-drive-item-content`, `convert-drive-item-*`, `extract-drive-item-images`, `get-drive-item`, …), because `driveId`/`itemId` carry no tenant and those commands would otherwise fail with `invalidAudienceUri`. When `tenantId` is absent the file is in your own tenant and no flag is needed. Any other field is absent/`undefined` when the resolved driveItem omits it (e.g. `size` on a folder)."
7403
7697
  },
7404
7698
  {
7405
7699
  "name": "resolve-mail-link",
@@ -7439,14 +7733,14 @@
7439
7733
  },
7440
7734
  {
7441
7735
  "name": "scopes-check",
7442
- "summary": "Decode the cached Teams web client access token and return its scopes, audience, and expiry without making a Graph call. Use this as a self-test before running a command an LLM expects to fail with `accessDenied` — if the required scope isn't in the returned list, the call will reject regardless of tenant config. Each command's `scopesRequired` field in `help-json` lists the scopes that command needs; intersect with the array returned here for a pre-flight check (pipe both through `jq` and diff). The `expiresInSeconds` field lets an LLM decide pre-emptively to `login` again — typically worth doing under ~5 minutes (300 s) so a long-running session doesn't hit the wall mid-command. The `elevated` block reports whether the *separate* M365ChatClient-elevated token (needed by the historical-version download / convert commands) is cached and still usable — so a fresh process can pre-flight `deep-scan`-style workloads instead of discovering a 403 mid-run; `available:false` when it is absent, expired, or within the same 5-minute buffer the download path applies. The `chatsvcagg` and `ic3` blocks report the two Teams-chat substrate tokens (used by `list-teams-chat*` / `find-chats-with-user`) the same way; both self-heal from the shared refresh token, so they are informational rather than a preflight gate.",
7736
+ "summary": "Decode the cached Teams web client access token and return its scopes, audience, and expiry without making a Graph call. Use this as a self-test before running a command an LLM expects to fail with `accessDenied` — if the required scope isn't in the returned list, the call will reject regardless of tenant config. Each command's `scopesRequired` field in `help-json` lists the scopes that command needs; intersect with the array returned here for a pre-flight check (pipe both through `jq` and diff). The `expiresInSeconds` field lets an LLM decide pre-emptively to `login` again — typically worth doing under ~5 minutes (300 s) so a long-running session doesn't hit the wall mid-command. The `elevated` block reports whether the *separate* M365ChatClient-elevated token (needed by the historical-version download / convert commands) is cached and still usable — so a fresh process can pre-flight `deep-scan`-style workloads instead of discovering a 403 mid-run; `available:false` when it is absent, expired, or within the same 5-minute buffer the download path applies. The `chatsvcagg` and `ic3` blocks report the two Teams-chat substrate tokens (used by `list-teams-chat*` / `find-chats-with-user`) the same way; both self-heal from the shared refresh token, so they are informational rather than a preflight gate. Every tier block also lists that token's OWN granted scopes (decoded from its `scp` claim, distinct per token) and its `refresh` route (`automatic` = self-heals from the shared refresh token; `interactive` = the elevated tier, which carries no refresh token of its own and is re-captured by a browser login); the `hint` field says how to refresh them.",
7443
7737
  "category": "meta",
7444
7738
  "graphMethod": "GET",
7445
7739
  "graphPathTemplate": "(meta) cached-token introspection — no Graph endpoint",
7446
7740
  "graphDocsUrl": "https://learn.microsoft.com/en-us/graph/permissions-reference",
7447
7741
  "options": [],
7448
7742
  "example": "ask-marcel-office scopes-check",
7449
- "responseShape": "`{ scopes: string[], audience: string, expiresAt: string (ISO 8601), expiresInSeconds: number, elevated: { available: boolean, expiresInSeconds?: number }, chatsvcagg: { available: boolean, expiresInSeconds?: number }, ic3: { available: boolean, expiresInSeconds?: number } }`. `expiresInSeconds` is negative when the cached token has already expired (run `login`); `audience` is the JWT `aud` claim (typically `https://graph.microsoft.com`). `elevated.available` is `true` only when the cached M365ChatClient-elevated token (used by the historical-version commands) is present and beyond the 5-minute buffer; `elevated.expiresInSeconds` is its raw remaining seconds and is omitted (the key is absent) when no elevated token is cached. `chatsvcagg` and `ic3` are the two Teams-chat substrate tokens, same shape as `elevated`; both self-heal from the shared refresh token, so they are informational rather than a preflight gate."
7743
+ "responseShape": "`{ scopes: string[], audience: string, expiresAt: string (ISO 8601), expiresInSeconds: number, elevated: TokenTier, chatsvcagg: TokenTier, ic3: TokenTier, hint: string }` where `TokenTier = { available: boolean, expiresInSeconds?: number, scopes: string[], refresh: \"automatic\" | \"interactive\", reason?: string }`. Top-level `scopes`/`audience`/`expiresAt`/`expiresInSeconds` describe the basic Teams token (back-compat). Each tier block also carries that token's OWN `scopes` (decoded from its `scp` claim: elevated ~20 Graph scopes, chatsvcagg `user_impersonation`, ic3 `Teams.AccessAsUser.All`) and its `refresh` route (`automatic` = self-heals from the shared refresh token; `interactive` = the elevated token, re-captured only by a browser login). `available` is `true` only when the token is present and beyond the 5-minute buffer; `expiresInSeconds` is the raw remaining seconds (negative when expired) and is omitted when the token is absent. `reason` is present ONLY when `available` is `false` a plain-language note on why the tier is missing and how to restore it (so an empty `scopes: []` on an absent token is not mistaken for \"no scopes\"); it is omitted when the token is available. `hint` names `login --force` as the single refresh action."
7450
7744
  },
7451
7745
  {
7452
7746
  "name": "search-all-accessible-sites",
@@ -7473,9 +7767,28 @@
7473
7767
  "responseShape": "`{ value: [<Microsoft Graph site resource: { id, name, displayName?, webUrl, size?, estimatedFileCount?, … }>], count, fileEstimate?, archivedExcluded?, nonNavigableExcluded?, notFoundExcluded?, archiveProbeErrors?, truncated?: true, archiveProbeTruncated?: true }`. `estimatedFileCount` appears only with `--count-files true` — the security-trimmed `driveItem` (files+folders) estimate scoped to each site, omitted past the 200-site count cap or when the per-site query fails. `value[]` is deduped by site `id` across pages; `count` is the number of distinct sites returned (the authoritative figure). `size` (when present) is the site's DEFAULT document library's total bytes used (`drive.quota.used`, recursive) — folded onto the same per-site probe for free, omitted for sites past the probe ceiling or without a default drive; it is a data-volume signal, not a file count. `fileEstimate` (best-effort, omitted if the extra query fails) is the Microsoft Search index's security-trimmed `driveItem` count — roughly how many files+folders the user can access across all of SharePoint/OneDrive. `truncated: true` means paging stopped early (page ceiling hit, or a later page errored) — narrow with `--query` to see the rest; its absence means the sweep ran to completion. `archivedExcluded` (omitted when 0) counts sites dropped as archived/locked by the per-site probe; `nonNavigableExcluded` counts sites dropped by URL shape (add-in app domains, `/contentstorage/` SharePoint Embedded containers, `/_layouts/` system pages); `notFoundExcluded` counts sites whose probe returned 404. `archiveProbeErrors` (omitted when 0) counts sites whose probe failed for an unrelated reason — those are KEPT, so an archived one could slip through. `archiveProbeTruncated: true` means more sites were returned than the probe ceiling (250); the overflow is kept unprobed — narrow with `--query`.",
7474
7768
  "bodyTemplate": "{ requests: [{ entityTypes: ['site'], query: { queryString: '{query}' }, from: <page*25>, size: 25 }] } — `{query}` defaults to `*` (all accessible sites); re-issued per page, advancing `from` by 25 until `moreResultsAvailable` is false"
7475
7769
  },
7770
+ {
7771
+ "name": "search-all-files",
7772
+ "summary": "Search EVERY file the signed-in user can access — their personal OneDrive, files shared with them, and every SharePoint and Teams (channel) document library they can open — for a free-text query. Unlike `search-my-documents` (personal OneDrive only) or `search-onedrive-files` (one drive by id), this reaches across all accessible drives via the security-trimmed Microsoft Search index; unlike `microsoft-search-query` it returns FILES ONLY (`entityTypes: [\"driveItem\"]`), not mail/calendar/people/sites. It deep-pages `POST /search/query` with `from`/`size` (200 per page) following the index's `moreResultsAvailable` flag until exhausted, or the ceiling of 25×200 = 5000 files is reached (`truncated: true` — narrow with `--query` to see the rest). Hits are deduped by `hitId`. Each returned `driveItem` carries `id` + `parentReference.driveId`, the pair `download-drive-item-content` / `download-drive-item-as-markdown` need to open it.",
7773
+ "category": "drive",
7774
+ "graphMethod": "POST",
7775
+ "graphPathTemplate": "/search/query",
7776
+ "graphDocsUrl": "https://learn.microsoft.com/en-us/graph/api/search-query",
7777
+ "options": [
7778
+ {
7779
+ "name": "query",
7780
+ "key": "query",
7781
+ "required": true,
7782
+ "description": "KQL / free-text search query. Matches filename, content, and metadata across every drive the user can access. Add `filetype:` to narrow (e.g. `q1 budget filetype:xlsx`), or field operators like `filename:`. Free text works everywhere."
7783
+ }
7784
+ ],
7785
+ "example": "ask-marcel-office search-all-files --query 'q1 budget filetype:xlsx'",
7786
+ "responseShape": "`{ value: [<Microsoft Graph driveItem resource: { id, name, webUrl, parentReference: { driveId }, size, … }>], count, truncated?: true }`. `value[]` is deduped by `hitId` across pages; `count` is the number of distinct files returned. `truncated: true` means paging stopped early (page ceiling hit, or a later page errored) — narrow with `--query` to see the rest; its absence means the sweep ran to completion.",
7787
+ "bodyTemplate": "{ requests: [{ entityTypes: ['driveItem'], query: { queryString: '{query}' }, from: <page*200>, size: 200 }] } — re-issued per page, advancing `from` by 200 until `moreResultsAvailable` is false or the 25-page ceiling is hit"
7788
+ },
7476
7789
  {
7477
7790
  "name": "search-mail-messages",
7478
- "summary": "Search the signed-in user's entire Outlook mailbox using KQL or free text. Results are ranked by Graph relevance. The CLI ships a slim default `--select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,hasAttachments,isRead,importance,bodyPreview` (same as `list-mail-messages`) so a 3-result page stays ~3 KB instead of ~30 KB. Pass `--select id,subject,body` to widen, or override entirely. Note: Graph does not allow `$search` and `$filter` together — the CLI rejects `--filter` client-side with a pointer to `list-mail-messages` (which supports OData filtering). For sorting, server-side `$orderby` is also not allowed with `$search`; use the relevance ranking Graph returns. **KQL quoting gotcha**: pass the raw KQL expression, e.g. `--query 'subject:invoice from:alice'`; do NOT wrap your terms in extra double-quotes (Graph then rejects with `BadRequest: An identifier was expected at position 0` because it sees `\"...\"` after the `$search=` interpolation). The CLI already wraps the entire `--query` value in `\"...\"` on the wire.",
7791
+ "summary": "Search the signed-in user's entire Outlook mailbox using KQL or free text. Results are ranked by Graph relevance. The CLI ships a slim default `--select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,hasAttachments,isRead,importance,bodyPreview,conversationId` (same as `list-mail-messages`; `conversationId` is included so you can group hits into a thread or feed one to `list-conversation-messages`) so a 3-result page stays ~3 KB instead of ~30 KB. Pass `--select id,subject,body` to widen, or override entirely. Note: Graph does not allow `$search` and `$filter` together — the CLI rejects `--filter` client-side with a pointer to `list-mail-messages` (which supports OData filtering). For sorting, server-side `$orderby` is also not allowed with `$search`; use the relevance ranking Graph returns. **Exact-phrase search works**: `--query '\"budget allocation\"'` and embedded field phrases like `--query 'subject:\"Contoso A2 & B7 timeline\"'` are supported the CLI escapes your double quotes into KQL phrase quotes, wraps the whole expression in the `\"…\"` Graph requires, and percent-encodes the value so `&`, `#`, and `+` are wire-safe. Pass raw KQL otherwise, e.g. `--query 'subject:invoice from:alice'`.",
7479
7792
  "category": "mail",
7480
7793
  "graphMethod": "GET",
7481
7794
  "graphPathTemplate": "/me/messages?$search=\"{query}\"",
@@ -7750,10 +8063,10 @@
7750
8063
  },
7751
8064
  {
7752
8065
  "name": "update-mail-draft",
7753
- "summary": "Update an existing mail draft. PATCH /me/messages/{id} — modifies a draft created by create-mail-draft (or any existing draft in the Drafts folder). Only the fields you pass are updated; omitted fields are left unchanged. At least one field must be provided. Returns the updated message object. Use get-mail-message to verify the final state before sending.",
8066
+ "summary": "Update an existing mail draft. PATCH /me/messages/{id} — modifies a draft created by create-mail-draft (or any existing draft in the Drafts folder). Only the fields you pass are updated; omitted fields are left unchanged. At least one field must be provided. On a THREADED draft (one made by create-reply-draft / create-forward-draft), revise your text with --comment, which rewrites only what sits above the quoted history and leaves the quote byte-identical; --body-content would replace the whole body and drop the thread. Passing an EMPTY string to a recipient flag clears that list, which is how you drop recipients a reply-all or forward inherited; omitting the flag leaves the list alone. Returns a slim confirmation (id, subject, recipients, importance, bodyPreview, …) - NOT the full body, which you just wrote; read it back with get-mail-message if you need the whole draft before sending.",
7754
8067
  "category": "mail",
7755
8068
  "graphMethod": "PATCH",
7756
- "graphPathTemplate": "/me/messages/{message-id}",
8069
+ "graphPathTemplate": "/me/messages/{message-id} (+ a GET of body,isDraft first when {comment} is used)",
7757
8070
  "graphDocsUrl": "https://learn.microsoft.com/en-us/graph/api/message-update",
7758
8071
  "options": [
7759
8072
  {
@@ -7781,13 +8094,19 @@
7781
8094
  "name": "body-content",
7782
8095
  "key": "bodyContent",
7783
8096
  "required": false,
7784
- "description": "New email body content. Replaces the entire body. Pass --body-content-type HTML for rich text."
8097
+ "description": "New email body content. Replaces the ENTIRE body, quoted history included. On a threaded reply or forward draft this is almost never what you want - use --comment to revise only your own text and keep the quote. Pass --body-content-type HTML for rich text. Mutually exclusive with --comment."
8098
+ },
8099
+ {
8100
+ "name": "comment",
8101
+ "key": "comment",
8102
+ "required": false,
8103
+ "description": "Rewrite ONLY the reply text above the quoted history on a threaded draft, keeping the quote and its styles byte-identical. This is the flag for revising a draft made by create-reply-draft or create-forward-draft; repeated edits replace your text rather than stacking. Refused when the draft has no quoted history (use --body-content), when it is not a draft, and when HTML markup you pass carries a quote boundary marker of its own. Mutually exclusive with --body-content."
7785
8104
  },
7786
8105
  {
7787
8106
  "name": "body-content-type",
7788
8107
  "key": "bodyContentType",
7789
8108
  "required": false,
7790
- "description": "Body format for the new body: Text (default) or HTML. Only used when --body-content is provided.",
8109
+ "description": "Format of --body-content, or of --comment in comment mode: Text (default) or HTML. With --comment on an HTML draft, Text is escaped into the draft (markup shows as characters) and HTML is spliced in as markup; HTML is refused on a plain-text draft. The draft keeps its own body format either way - this flag describes what you are passing, not what the draft becomes.",
7791
8110
  "argumentHint": {
7792
8111
  "kind": "magicValue",
7793
8112
  "values": [
@@ -7800,19 +8119,19 @@
7800
8119
  "name": "to-recipients",
7801
8120
  "key": "toRecipients",
7802
8121
  "required": false,
7803
- "description": "Comma-separated list of recipient email addresses. Replaces the entire toRecipients list."
8122
+ "description": "Comma-separated list of recipient email addresses. Replaces the entire toRecipients list. Pass an empty string to CLEAR the list (the only way to drop recipients a reply or forward inherited)."
7804
8123
  },
7805
8124
  {
7806
8125
  "name": "cc-recipients",
7807
8126
  "key": "ccRecipients",
7808
8127
  "required": false,
7809
- "description": "Comma-separated list of CC recipient email addresses. Replaces the entire ccRecipients list."
8128
+ "description": "Comma-separated list of CC recipient email addresses. Replaces the entire ccRecipients list. Pass an empty string to CLEAR the list (the only way to drop recipients a reply or forward inherited)."
7810
8129
  },
7811
8130
  {
7812
8131
  "name": "bcc-recipients",
7813
8132
  "key": "bccRecipients",
7814
8133
  "required": false,
7815
- "description": "Comma-separated list of BCC recipient email addresses. Replaces the entire bccRecipients list."
8134
+ "description": "Comma-separated list of BCC recipient email addresses. Replaces the entire bccRecipients list. Pass an empty string to CLEAR the list (the only way to drop recipients a reply or forward inherited)."
7816
8135
  },
7817
8136
  {
7818
8137
  "name": "importance",
@@ -7830,8 +8149,8 @@
7830
8149
  }
7831
8150
  ],
7832
8151
  "example": "ask-marcel-office update-mail-draft --message-id \"AAMkAD...\" --subject \"Updated: Q3 Report\" --to-recipients \"alice@example.com,charlie@example.com\"",
7833
- "responseShape": "The updated Microsoft Graph message object: `{ id, subject, body, from, toRecipients, ccRecipients, bccRecipients, receivedDateTime, isDraft, }`. Graph returns 204 No Content on success with no body — the CLI surfaces `{ ok: true }` in that case.",
7834
- "bodyTemplate": "{ subject?: '{subject}', body?: { contentType: '{body-content-type}', content: '{body-content}' }, toRecipients?: '{to-recipients}', ccRecipients?: '{cc-recipients}', bccRecipients?: '{bcc-recipients}', importance?: '{importance}' } — only provided fields are sent",
8152
+ "responseShape": "A confirmation of the write, NOT the whole message: `{ id, subject, toRecipients, ccRecipients, bccRecipients, importance, bodyPreview, isDraft, webLink, conversationId }` (only the fields Graph returned; `{ ok: true }` when Graph answers 204). The `body` is deliberately omitted you just wrote it, and echoing a long thread's quoted history back cost ~174 KB of context per call. Read the full body with `get-mail-message --id <the returned id>` when you actually need it; `bodyPreview` is Graph's ~255-char summary, enough to confirm WHICH draft answered. The `id` is the draft — refine it with `update-mail-draft`, or open Outlook Drafts to review and send. Dedup caveat: a `conversationId` `$filter` on the Drafts folder is not a reliable 'does a draft already exist on this thread' check. Reply and forward drafts do not always inherit the inbound message's `conversationId` (one thread can split across several), and Graph `$filter` on Drafts is not read-your-writes consistent, so a just-created draft can be missed. To find existing drafts, use the `find-mail-drafts` command, which scans recent drafts and matches client-side on subject and recipients; to revise a draft this session created, reuse the returned `id`.",
8153
+ "bodyTemplate": "{ subject?: '{subject}', body?: { contentType: '{body-content-type}', content: '{body-content}' }, toRecipients?: '{to-recipients}', ccRecipients?: '{cc-recipients}', bccRecipients?: '{bcc-recipients}', importance?: '{importance}' } — only provided fields are sent. With '{comment}': body.content is the draft's own body with the text above the quote replaced, and body.contentType is the draft's own, unchanged",
7835
8154
  "mutates": true
7836
8155
  }
7837
8156
  ]