ask-marcel-office-cli 1.5.0 → 1.5.2

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,37 @@
2
2
 
3
3
  All notable changes to `ask-marcel-office-cli` are documented here.
4
4
 
5
+ ## 1.5.2
6
+
7
+ ### Fixed
8
+
9
+ - **`find-chats-with-user` now surfaces cross-tenant 1:1 chats.** An externally-homed
10
+ counterpart comes back from the Teams chat roster as a bare object-id — no name, no
11
+ email — so a name search could never match it, and a real, active 1:1 returned a
12
+ silent `matchCount: 0`. The command now hydrates every bare **direct (1:1)** chat via
13
+ `/chats/{id}/members` and re-runs the match, so a cross-tenant counterpart is found
14
+ even when they were already resolved in a meeting under a different identity. When
15
+ nothing matches but bare members remain, it returns a `hint` + `unresolvedMemberCount`
16
+ rather than a confidently-empty result.
17
+ - **`list-chat-members` reads on the basic Teams token** (`ChatMember.Read`) instead of
18
+ the login-only elevated (M365ChatClient) token, so it no longer fails with "Elevated
19
+ token expired" on the command path. `next-page` routes `/chats/{id}/members` cursors on
20
+ the basic token to match. Chat _metadata_ (`list-chats` / `get-chat`) still requires
21
+ the elevated token.
22
+
23
+ ## 1.5.1
24
+
25
+ ### Fixed
26
+
27
+ - **No browser window opens per command once a secondary token lapses** (completes
28
+ the 1.5.0 auth fix). The elevated / Teams-chat (chatsvcagg / ic3) token recaptures
29
+ each launched a _visible_ browser that "opens and closes within seconds" to
30
+ silently re-capture — per command, per process, with no cross-process throttle —
31
+ so after the short-lived elevated token (~59 min) expired, every elevated or
32
+ Teams-chat command popped a window. The command-path auth now **fails fast** with
33
+ an actionable "run `ask-marcel login`" instead; browser capture is reserved for
34
+ the explicit `login` command, which re-captures all four tokens in one session.
35
+
5
36
  ## 1.5.0
6
37
 
7
38
  Agent-ergonomics, a faster cold-start, a new command, and an LLM-safety pass.
@@ -50,11 +81,11 @@ change to note**: byte commands now refuse to inline a payload over ~1 MB withou
50
81
 
51
82
  ### Fixed
52
83
 
53
- - **Auth no longer pops a browser per command on re-auth.** A command whose token
54
- needed refreshing used to launch the Chrome extension-capture window — once per
55
- process, with no cross-process throttle, so a batch/agent run stacked up
56
- windows. Command re-auth now uses a headed Edge sign-in (one login re-caches all
57
- tokens); the extension capture is reserved for explicit `login --use-extension`.
84
+ - **Auth no longer pops a browser per command on re-auth.** The primary-token
85
+ refresh-fallback used to launch the Chrome extension-capture window — once per
86
+ process, with no cross-process throttle, so a batch/agent run stacked up windows.
87
+ Command re-auth now uses a headed Edge sign-in only when the silent refresh
88
+ genuinely fails; the extension capture is reserved for explicit `login --use-extension`.
58
89
  - **Stale doc numbers** — the `help-json` size hints (terse-category ~16 → ~6 KB
59
90
  after the trim) and the README command list now match reality.
60
91
 
package/dist/cli.js CHANGED
@@ -2457,7 +2457,7 @@ import updateNotifier from "update-notifier";
2457
2457
  // package.json
2458
2458
  var package_default = {
2459
2459
  name: "ask-marcel-office-cli",
2460
- version: "1.5.0",
2460
+ version: "1.5.2",
2461
2461
  description: "Microsoft Graph CLI + library — typed Bun/TypeScript wrapper around 150+ Graph operations (read + on-the-fly PDF/markdown conversion + federated Microsoft Search) reachable from a Teams browser-OAuth token.",
2462
2462
  license: "MIT",
2463
2463
  author: "Vincent Delacourt <vincent.delacourt@adama-development.com>",
@@ -3475,7 +3475,8 @@ var SCOPES = "https://graph.microsoft.com/.default openid profile offline_access
3475
3475
  var SPA_ORIGIN = "https://teams.microsoft.com";
3476
3476
  var TEAMS_URL2 = "https://teams.microsoft.com/";
3477
3477
  var DEFAULT_CHATSVCAGG_REGION2 = "emea";
3478
- var createAuthManagerFromApi = (browserAuth, cachePath, browserProfileDir, logger, fs, systemBrowserAuthFn, usePlaywrightFallback = true, skipSystemBrowser = false) => {
3478
+ var failFastSecondaryMessage = (token, commands) => `${token} token is expired or was not captured at login. Run \`ask-marcel login\` to (re)capture it — the CLI does not open a browser per command for this token. (Commands that need it: ${commands}.)`;
3479
+ var createAuthManagerFromApi = (browserAuth, cachePath, browserProfileDir, logger, fs, systemBrowserAuthFn, usePlaywrightFallback = true, skipSystemBrowser = false, recaptureSecondaryViaBrowser = true) => {
3479
3480
  const systemBrowserAuth = systemBrowserAuthFn ?? defaultSystemBrowserAuth(logger, skipSystemBrowser);
3480
3481
  const readCache = async () => {
3481
3482
  const r = await fs.readJson(cachePath);
@@ -3657,12 +3658,12 @@ var createAuthManagerFromApi = (browserAuth, cachePath, browserProfileDir, logge
3657
3658
  };
3658
3659
  const recoverableElevatedFailureMessage = (reason) => {
3659
3660
  if (reason === "launch_timeout") {
3660
- return "elevated browser launch timed out (15s) — likely a corrupt persistent profile or filesystem lock. Run `ask-marcel logout && ask-marcel login` to wipe the profile and retry. (Commands that need this token: list-chats, get-chat, list-chat-members, the historical-version download / convert commands.)";
3661
+ return "elevated browser launch timed out (15s) — likely a corrupt persistent profile or filesystem lock. Run `ask-marcel logout && ask-marcel login` to wipe the profile and retry. (Commands that need this token: list-chats, get-chat, the historical-version download / convert commands.)";
3661
3662
  }
3662
3663
  if (reason === "navigation_failed") {
3663
- return "elevated capture failed: navigation to m365.cloud.microsoft did not complete — network issue, corp-proxy block, or tenant policy. Check connectivity and retry. If persistent, the elevated commands (list-chats / get-chat / list-chat-members / historical-version downloads) will be unavailable.";
3664
+ return "elevated capture failed: navigation to m365.cloud.microsoft did not complete — network issue, corp-proxy block, or tenant policy. Check connectivity and retry. If persistent, the elevated commands (list-chats / get-chat / historical-version downloads) will be unavailable.";
3664
3665
  }
3665
- return "elevated token capture timed out — silent SSO against m365.cloud.microsoft did not yield a Bearer within 20s. The persistent browser-profile cookies are likely expired. Run `ask-marcel logout && ask-marcel login` — this now wipes the profile too. (Commands that need this token: list-chats, get-chat, list-chat-members, the historical-version download / convert commands.)";
3666
+ return "elevated token capture timed out — silent SSO against m365.cloud.microsoft did not yield a Bearer within 20s. The persistent browser-profile cookies are likely expired. Run `ask-marcel logout && ask-marcel login` — this now wipes the profile too. (Commands that need this token: list-chats, get-chat, the historical-version download / convert commands.)";
3666
3667
  };
3667
3668
  const recaptureElevated = async () => {
3668
3669
  try {
@@ -3697,6 +3698,8 @@ var createAuthManagerFromApi = (browserAuth, cachePath, browserProfileDir, logge
3697
3698
  logger.info("auth.elevated.cache_hit");
3698
3699
  return ok(validated.value);
3699
3700
  }
3701
+ if (!recaptureSecondaryViaBrowser)
3702
+ return err({ type: "auth_failed", message: failFastSecondaryMessage("Elevated (M365)", "list-chats, get-chat, download-drive-item-version") });
3700
3703
  return recaptureElevatedShared();
3701
3704
  };
3702
3705
  const freshChatsvcaggToken = (cached) => {
@@ -3747,6 +3750,11 @@ var createAuthManagerFromApi = (browserAuth, cachePath, browserProfileDir, logge
3747
3750
  logger.info("auth.chatsvcagg.cache_hit");
3748
3751
  return ok(accessTokenUnsafe(fresh));
3749
3752
  }
3753
+ if (!recaptureSecondaryViaBrowser)
3754
+ return err({
3755
+ type: "auth_failed",
3756
+ message: failFastSecondaryMessage("chatsvcagg (Teams chat)", "list-teams-chats-with-messages, list-teams-chat-messages, get-teams-chat-message, find-chats-with-user")
3757
+ });
3750
3758
  return recaptureChatsvcaggShared();
3751
3759
  };
3752
3760
  const getChatsvcaggRegion = async () => {
@@ -3802,6 +3810,8 @@ var createAuthManagerFromApi = (browserAuth, cachePath, browserProfileDir, logge
3802
3810
  logger.info("auth.ic3.cache_hit");
3803
3811
  return ok(accessTokenUnsafe(fresh));
3804
3812
  }
3813
+ if (!recaptureSecondaryViaBrowser)
3814
+ return err({ type: "auth_failed", message: failFastSecondaryMessage("ic3 (Teams chat history)", "list-teams-chat-history") });
3805
3815
  return recaptureIc3Shared();
3806
3816
  };
3807
3817
  const logout = async () => {
@@ -3859,7 +3869,7 @@ var createAuthManager = (deps) => {
3859
3869
  freshCachedToken: createFreshCachedTokenProbe(fs, deps.cachePath),
3860
3870
  onProgress: stderrProgress
3861
3871
  });
3862
- return createAuthManagerFromApi(browserAuth, deps.cachePath, browserProfileDir, deps.logger, fs, deps.systemBrowserAuth, deps.usePlaywrightFallback, deps.skipSystemBrowser);
3872
+ return createAuthManagerFromApi(browserAuth, deps.cachePath, browserProfileDir, deps.logger, fs, deps.systemBrowserAuth, deps.usePlaywrightFallback, deps.skipSystemBrowser, deps.recaptureSecondaryViaBrowser);
3863
3873
  };
3864
3874
  // src/infra/network-error.ts
3865
3875
  var REQUEST_TIMEOUT_MS = 60000;
@@ -4338,7 +4348,7 @@ var buildDeps = (config = {}) => {
4338
4348
  const processRunner = config.processRunner ?? defaultProcessRunner();
4339
4349
  const logger = createWinstonLogger({ logLevel });
4340
4350
  const makeAuth = config.createAuth ?? createAuthManager;
4341
- const auth = makeAuth({ cachePath, logger, fs, skipSystemBrowser: true, usePlaywrightFallback: true });
4351
+ const auth = makeAuth({ cachePath, logger, fs, skipSystemBrowser: true, usePlaywrightFallback: true, recaptureSecondaryViaBrowser: false });
4342
4352
  const graph = createGraphClient(auth);
4343
4353
  const makeLoginAuth = ({ useExtension }) => makeAuth({ cachePath, logger, fs, skipSystemBrowser: !useExtension, usePlaywrightFallback: !useExtension });
4344
4354
  return { logger, auth, graph, processRunner, fs, makeLoginAuth };
@@ -23004,7 +23014,7 @@ __export(exports_list_chat_members, {
23004
23014
  });
23005
23015
  var baseSchema21 = exports_external.object({ chatId: exports_external.string().min(1) });
23006
23016
  var CHAT_MEMBERS_ODATA_KEYS = ["skip", "select", "filter"];
23007
- var inner5 = buildElevatedPickODataListCommand((p) => `/chats/${p.chatId}/members`, baseSchema21, CHAT_MEMBERS_ODATA_KEYS);
23017
+ var inner5 = buildPickODataListCommand((p) => `/chats/${p.chatId}/members`, baseSchema21, CHAT_MEMBERS_ODATA_KEYS);
23008
23018
  var execute42 = async (graph, params) => {
23009
23019
  const result = await inner5.execute(graph, params);
23010
23020
  if (result.ok)
@@ -23039,8 +23049,7 @@ var meta44 = {
23039
23049
  ],
23040
23050
  example: "ask-marcel list-chat-members --chat-id '19:abc...@thread.v2'",
23041
23051
  responseShape: "collection of Microsoft Graph `conversationMember` resources under `value[]`",
23042
- pagination: true,
23043
- needsElevatedToken: true
23052
+ pagination: true
23044
23053
  };
23045
23054
 
23046
23055
  // src/use-cases/commands/list-drive-item-permissions.ts
@@ -24002,8 +24011,13 @@ __export(exports_next_page, {
24002
24011
  execute: () => execute74
24003
24012
  });
24004
24013
  var PREFIX = "https://graph.microsoft.com/v1.0";
24005
- var ELEVATED_PATH_PREFIXES = ["/me/chats", "/chats/"];
24006
- var requiresElevated = (path) => ELEVATED_PATH_PREFIXES.some((prefix) => path.startsWith(prefix));
24014
+ var requiresElevated = (path) => {
24015
+ if (path.startsWith("/me/chats"))
24016
+ return true;
24017
+ if (path.startsWith("/chats/"))
24018
+ return !path.includes("/members");
24019
+ return false;
24020
+ };
24007
24021
  var schema74 = exports_external.object({
24008
24022
  url: exports_external.string().min(1).refine((v) => v.startsWith(`${PREFIX}/`), { message: `must be a Microsoft Graph v1.0 URL starting with ${PREFIX}/` })
24009
24023
  });
@@ -26621,15 +26635,43 @@ var projectMember = (m) => {
26621
26635
  out.userSubType = m.userSubType;
26622
26636
  return out;
26623
26637
  };
26624
- var execute107 = async (graph, params) => {
26625
- const parsed = schema107.safeParse(params);
26626
- if (!parsed.success)
26627
- return err({ type: "validation_error", message: formatZodError(parsed.error) });
26628
- const queryFolded = fold(parsed.data.name);
26629
- const pageSize = parsed.data.pageSize ?? "100";
26630
- const maxPages = Number(parsed.data.maxPages ?? "10");
26631
- const matched = [];
26632
- const seenChatIds = new Set;
26638
+ var isNameResolvable = (m) => {
26639
+ const fields = [m.displayName, m.email, m.userPrincipalName, m.givenName, m.surname];
26640
+ return fields.some((f) => typeof f === "string" && f.trim() !== "");
26641
+ };
26642
+ var bareMemberCount = (members) => members.filter((m) => !isNameResolvable(m)).length;
26643
+ var fromGraphMember = (g) => ({
26644
+ ...g.displayName !== undefined ? { displayName: g.displayName } : {},
26645
+ ...g.email !== undefined ? { email: g.email } : {},
26646
+ ...typeof g.userId === "string" ? { mri: `8:orgid:${g.userId}`, objectId: g.userId } : {}
26647
+ });
26648
+ var toMatchedChat = (chatId, chat, members) => ({
26649
+ chatId,
26650
+ title: chat.title ?? null,
26651
+ chatType: chat.chatType,
26652
+ threadType: chat.threadType,
26653
+ memberCount: (chat.members ?? []).length,
26654
+ ...chat.lastMessage?.composeTime !== undefined ? { lastMessageAt: chat.lastMessage.composeTime } : {},
26655
+ matchedMembers: members.map(projectMember)
26656
+ });
26657
+ var collectChat = (chat, queryFolded, acc) => {
26658
+ if (chat.id === undefined || acc.seen.has(chat.id))
26659
+ return;
26660
+ const members = chat.members ?? [];
26661
+ const hits = members.filter((m) => memberMatches(m, queryFolded));
26662
+ if (hits.length > 0) {
26663
+ acc.seen.add(chat.id);
26664
+ acc.matched.push(toMatchedChat(chat.id, chat, hits));
26665
+ return;
26666
+ }
26667
+ const bareCount = bareMemberCount(members);
26668
+ if (bareCount > 0) {
26669
+ acc.seen.add(chat.id);
26670
+ acc.bareUnmatched.push({ chatId: chat.id, chat, bareCount });
26671
+ }
26672
+ };
26673
+ var scanChatPages = async (graph, queryFolded, pageSize, maxPages) => {
26674
+ const acc = { seen: new Set, matched: [], bareUnmatched: [] };
26633
26675
  let continuationToken;
26634
26676
  let pagesFetched = 0;
26635
26677
  let chatsScanned = 0;
@@ -26644,42 +26686,67 @@ var execute107 = async (graph, params) => {
26644
26686
  const chats = body.chats ?? [];
26645
26687
  pagesFetched += 1;
26646
26688
  chatsScanned += chats.length;
26647
- for (const chat of chats) {
26648
- if (chat.id === undefined || seenChatIds.has(chat.id))
26649
- continue;
26650
- const members = chat.members ?? [];
26651
- const matchedMembers = members.filter((m) => memberMatches(m, queryFolded));
26652
- if (matchedMembers.length === 0)
26653
- continue;
26654
- seenChatIds.add(chat.id);
26655
- matched.push({
26656
- chatId: chat.id,
26657
- title: chat.title ?? null,
26658
- chatType: chat.chatType,
26659
- threadType: chat.threadType,
26660
- memberCount: members.length,
26661
- ...chat.lastMessage?.composeTime !== undefined ? { lastMessageAt: chat.lastMessage.composeTime } : {},
26662
- matchedMembers: matchedMembers.map(projectMember)
26663
- });
26664
- }
26689
+ for (const chat of chats)
26690
+ collectChat(chat, queryFolded, acc);
26665
26691
  if (body.hasMoreData !== true || body.continuationToken === undefined) {
26666
26692
  continuationToken = undefined;
26667
26693
  break;
26668
26694
  }
26669
26695
  continuationToken = body.continuationToken;
26670
26696
  }
26697
+ return ok({ matched: acc.matched, bareUnmatched: acc.bareUnmatched, pagesFetched, chatsScanned, continuationToken });
26698
+ };
26699
+ var sumBareCounts = (bare) => bare.reduce((n, b) => n + b.bareCount, 0);
26700
+ var hydrateMatches = async (graph, chatId, queryFolded) => {
26701
+ const res = await graph.get(`/chats/${chatId}/members`);
26702
+ if (!res.ok)
26703
+ return null;
26704
+ const value = res.value.value ?? [];
26705
+ return value.map(fromGraphMember).filter((m) => memberMatches(m, queryFolded));
26706
+ };
26707
+ var isDirectChat = (chatId) => chatId.endsWith("@unq.gbl.spaces");
26708
+ var hydrateBareDirect = async (graph, queryFolded, bareUnmatched, matched) => {
26709
+ const direct = bareUnmatched.filter((b) => isDirectChat(b.chatId));
26710
+ let unresolvedMemberCount = sumBareCounts(bareUnmatched.filter((b) => !isDirectChat(b.chatId)));
26711
+ const results = await Promise.all(direct.map(async (b) => ({ bare: b, hits: await hydrateMatches(graph, b.chatId, queryFolded) })));
26712
+ for (const { bare, hits } of results) {
26713
+ if (hits === null) {
26714
+ unresolvedMemberCount += bare.bareCount;
26715
+ continue;
26716
+ }
26717
+ if (hits.length > 0)
26718
+ matched.push(toMatchedChat(bare.chatId, bare.chat, hits));
26719
+ }
26720
+ return { chatsHydrated: direct.length, unresolvedMemberCount };
26721
+ };
26722
+ var HINT2 = "No chat member matched by name, but at least one chat has a cross-tenant member the Teams roster left unresolved — an externally-homed counterpart often appears only as a bare object-id. Direct 1:1 chats were deep-probed; members in group/meeting chats were not. Retry searching by their object-id (pass it as `--name <object-id>`), or, if you have the chat URL, read it directly with `get-chat` / `list-teams-chat-messages --chat-id 19:<their-oid>_<your-oid>@unq.gbl.spaces`.";
26723
+ var execute107 = async (graph, params) => {
26724
+ const parsed = schema107.safeParse(params);
26725
+ if (!parsed.success)
26726
+ return err({ type: "validation_error", message: formatZodError(parsed.error) });
26727
+ const queryFolded = fold(parsed.data.name);
26728
+ const pageSize = parsed.data.pageSize ?? "100";
26729
+ const maxPages = Number(parsed.data.maxPages ?? "20");
26730
+ const scan = await scanChatPages(graph, queryFolded, pageSize, maxPages);
26731
+ if (!scan.ok)
26732
+ return scan;
26733
+ const { matched, bareUnmatched, pagesFetched, chatsScanned, continuationToken } = scan.value;
26734
+ const { chatsHydrated, unresolvedMemberCount } = await hydrateBareDirect(graph, queryFolded, bareUnmatched, matched);
26671
26735
  return ok({
26672
26736
  name: parsed.data.name,
26673
26737
  matches: matched,
26674
26738
  matchCount: matched.length,
26675
26739
  pagesFetched,
26676
26740
  chatsScanned,
26741
+ chatsHydrated,
26742
+ unresolvedMemberCount,
26677
26743
  hasMore: continuationToken !== undefined,
26678
- nextContinuationToken: continuationToken
26744
+ nextContinuationToken: continuationToken,
26745
+ ...matched.length === 0 && unresolvedMemberCount > 0 ? { hint: HINT2 } : {}
26679
26746
  });
26680
26747
  };
26681
26748
  var meta109 = {
26682
- summary: 'Find every Microsoft Teams chat that includes a member matching `--name` (substring search across display-name, email, given-name, surname, MRI, and object-id). Both sides are Unicode-folded (NFD + combining-mark strip) and lowercased before comparison, so `--name Jane` matches `Jane DOE` AND `jane.doe@example.com` AND `JANE` — important because a dual-identity user often carries the accented display-name on one identity and the un-accented email on the other. Walks the paginated chat-list substrate up to `--max-pages` and returns matching chats with their `matchedMembers[]`. Collapses the canonical "all conversations with person X" workflow into a single call AND surfaces dual-identity people (e.g. someone with both an org MRI and a guest-tenant MRI). **Best-effort, may break on Microsoft client updates** — the chat substrate is not in the public Microsoft Graph API.',
26749
+ summary: 'Find every Microsoft Teams chat that includes a member matching `--name` (substring search across display-name, email, given-name, surname, MRI, and object-id). Both sides are Unicode-folded (NFD + combining-mark strip) and lowercased before comparison, so `--name Jane` matches `Jane DOE` AND `jane.doe@example.com` AND `JANE` — important because a dual-identity user often carries the accented display-name on one identity and the un-accented email on the other. Walks the paginated chat-list substrate up to `--max-pages` and returns matching chats with their `matchedMembers[]`. Collapses the canonical "all conversations with person X" workflow into a single call AND surfaces dual-identity people (e.g. someone with both an org MRI and a guest-tenant MRI). Cross-tenant resolution: the summary roster returns externally-homed counterparts as a bare object-id (no name/email), which a name search cannot match; for every bare DIRECT (1:1) chat the command hydrates the roster via the per-chat members endpoint and re-matches — so an external counterpart who is bare in your 1:1 is still found, even when they were already resolved in some meeting (the dual-identity case). Bare members in group/meeting chats are not deep-probed; when nothing matches and such members exist it returns a `hint` plus `unresolvedMemberCount` rather than a confident empty result. **Best-effort, may break on Microsoft client updates** — the chat substrate is not in the public Microsoft Graph API.',
26683
26750
  category: "chats",
26684
26751
  needsSubstrateToken: true,
26685
26752
  graphMethod: "GET",
@@ -26696,7 +26763,7 @@ var meta109 = {
26696
26763
  name: "max-pages",
26697
26764
  key: "maxPages",
26698
26765
  required: false,
26699
- description: "Safety cap on the chat-list walk (positive integer; default 10). Each page returns up to `--page-size` chats. Raise carefully on busy accounts every page is one HTTP round-trip."
26766
+ description: "Safety cap on the chat-list walk (positive integer; default 20). The walk stops early once the substrate reports no more pages, so the cap only bites on accounts with more chats than `--max-pages × --page-size`; raise it (and watch `hasMore`) if a known chat is missed. Each page is one HTTP round-trip."
26700
26767
  },
26701
26768
  {
26702
26769
  name: "page-size",
@@ -26706,7 +26773,7 @@ var meta109 = {
26706
26773
  }
26707
26774
  ],
26708
26775
  example: "ask-marcel find-chats-with-user --name 'Jane DOE'",
26709
- responseShape: "`{ name, matches: [{ chatId, title, chatType, threadType, memberCount, lastMessageAt?, matchedMembers: [{ mri, displayName, email, userSubType }] }], matchCount, pagesFetched, chatsScanned, hasMore, nextContinuationToken? }`. `matchedMembers` always carries the matching entries' identifying fields — pass `chatId` into `list-teams-chat-history` to read message bodies. `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`).",
26776
+ 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`).",
26710
26777
  stability: "experimental"
26711
26778
  };
26712
26779
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "package": "ask-marcel-office-cli",
3
- "version": "1.5.0",
4
- "generatedAt": "2026-06-16T16:14:42.738Z",
3
+ "version": "1.5.2",
4
+ "generatedAt": "2026-06-23T11:35:14.255Z",
5
5
  "commands": [
6
6
  {
7
7
  "name": "convert-calendar-event-attachment-to-markdown",
@@ -666,7 +666,7 @@
666
666
  },
667
667
  {
668
668
  "name": "find-chats-with-user",
669
- "summary": "Find every Microsoft Teams chat that includes a member matching `--name` (substring search across display-name, email, given-name, surname, MRI, and object-id). Both sides are Unicode-folded (NFD + combining-mark strip) and lowercased before comparison, so `--name Jane` matches `Jane DOE` AND `jane.doe@example.com` AND `JANE` — important because a dual-identity user often carries the accented display-name on one identity and the un-accented email on the other. Walks the paginated chat-list substrate up to `--max-pages` and returns matching chats with their `matchedMembers[]`. Collapses the canonical \"all conversations with person X\" workflow into a single call AND surfaces dual-identity people (e.g. someone with both an org MRI and a guest-tenant MRI). **Best-effort, may break on Microsoft client updates** — the chat substrate is not in the public Microsoft Graph API.",
669
+ "summary": "Find every Microsoft Teams chat that includes a member matching `--name` (substring search across display-name, email, given-name, surname, MRI, and object-id). Both sides are Unicode-folded (NFD + combining-mark strip) and lowercased before comparison, so `--name Jane` matches `Jane DOE` AND `jane.doe@example.com` AND `JANE` — important because a dual-identity user often carries the accented display-name on one identity and the un-accented email on the other. Walks the paginated chat-list substrate up to `--max-pages` and returns matching chats with their `matchedMembers[]`. Collapses the canonical \"all conversations with person X\" workflow into a single call AND surfaces dual-identity people (e.g. someone with both an org MRI and a guest-tenant MRI). Cross-tenant resolution: the summary roster returns externally-homed counterparts as a bare object-id (no name/email), which a name search cannot match; for every bare DIRECT (1:1) chat the command hydrates the roster via the per-chat members endpoint and re-matches — so an external counterpart who is bare in your 1:1 is still found, even when they were already resolved in some meeting (the dual-identity case). Bare members in group/meeting chats are not deep-probed; when nothing matches and such members exist it returns a `hint` plus `unresolvedMemberCount` rather than a confident empty result. **Best-effort, may break on Microsoft client updates** — the chat substrate is not in the public Microsoft Graph API.",
670
670
  "category": "chats",
671
671
  "graphMethod": "GET",
672
672
  "graphPathTemplate": "https://teams.microsoft.com/api/csa/{region}/api/v3/teams/users/me/chats",
@@ -682,7 +682,7 @@
682
682
  "name": "max-pages",
683
683
  "key": "maxPages",
684
684
  "required": false,
685
- "description": "Safety cap on the chat-list walk (positive integer; default 10). Each page returns up to `--page-size` chats. Raise carefully on busy accounts every page is one HTTP round-trip."
685
+ "description": "Safety cap on the chat-list walk (positive integer; default 20). The walk stops early once the substrate reports no more pages, so the cap only bites on accounts with more chats than `--max-pages × --page-size`; raise it (and watch `hasMore`) if a known chat is missed. Each page is one HTTP round-trip."
686
686
  },
687
687
  {
688
688
  "name": "page-size",
@@ -692,7 +692,7 @@
692
692
  }
693
693
  ],
694
694
  "example": "ask-marcel find-chats-with-user --name 'Jane DOE'",
695
- "responseShape": "`{ name, matches: [{ chatId, title, chatType, threadType, memberCount, lastMessageAt?, matchedMembers: [{ mri, displayName, email, userSubType }] }], matchCount, pagesFetched, chatsScanned, hasMore, nextContinuationToken? }`. `matchedMembers` always carries the matching entries' identifying fields — pass `chatId` into `list-teams-chat-history` to read message bodies. `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`).",
695
+ "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`).",
696
696
  "needsSubstrateToken": true,
697
697
  "stability": "experimental"
698
698
  },
@@ -2987,8 +2987,7 @@
2987
2987
  ],
2988
2988
  "example": "ask-marcel list-chat-members --chat-id '19:abc...@thread.v2'",
2989
2989
  "responseShape": "collection of Microsoft Graph `conversationMember` resources under `value[]`",
2990
- "pagination": true,
2991
- "needsElevatedToken": true
2990
+ "pagination": true
2992
2991
  },
2993
2992
  {
2994
2993
  "name": "list-chats",
package/dist/index.js CHANGED
@@ -1249,7 +1249,8 @@ var SCOPES = "https://graph.microsoft.com/.default openid profile offline_access
1249
1249
  var SPA_ORIGIN = "https://teams.microsoft.com";
1250
1250
  var TEAMS_URL2 = "https://teams.microsoft.com/";
1251
1251
  var DEFAULT_CHATSVCAGG_REGION2 = "emea";
1252
- var createAuthManagerFromApi = (browserAuth, cachePath, browserProfileDir, logger, fs, systemBrowserAuthFn, usePlaywrightFallback = true, skipSystemBrowser = false) => {
1252
+ var failFastSecondaryMessage = (token, commands) => `${token} token is expired or was not captured at login. Run \`ask-marcel login\` to (re)capture it — the CLI does not open a browser per command for this token. (Commands that need it: ${commands}.)`;
1253
+ var createAuthManagerFromApi = (browserAuth, cachePath, browserProfileDir, logger, fs, systemBrowserAuthFn, usePlaywrightFallback = true, skipSystemBrowser = false, recaptureSecondaryViaBrowser = true) => {
1253
1254
  const systemBrowserAuth = systemBrowserAuthFn ?? defaultSystemBrowserAuth(logger, skipSystemBrowser);
1254
1255
  const readCache = async () => {
1255
1256
  const r = await fs.readJson(cachePath);
@@ -1431,12 +1432,12 @@ var createAuthManagerFromApi = (browserAuth, cachePath, browserProfileDir, logge
1431
1432
  };
1432
1433
  const recoverableElevatedFailureMessage = (reason) => {
1433
1434
  if (reason === "launch_timeout") {
1434
- return "elevated browser launch timed out (15s) — likely a corrupt persistent profile or filesystem lock. Run `ask-marcel logout && ask-marcel login` to wipe the profile and retry. (Commands that need this token: list-chats, get-chat, list-chat-members, the historical-version download / convert commands.)";
1435
+ return "elevated browser launch timed out (15s) — likely a corrupt persistent profile or filesystem lock. Run `ask-marcel logout && ask-marcel login` to wipe the profile and retry. (Commands that need this token: list-chats, get-chat, the historical-version download / convert commands.)";
1435
1436
  }
1436
1437
  if (reason === "navigation_failed") {
1437
- return "elevated capture failed: navigation to m365.cloud.microsoft did not complete — network issue, corp-proxy block, or tenant policy. Check connectivity and retry. If persistent, the elevated commands (list-chats / get-chat / list-chat-members / historical-version downloads) will be unavailable.";
1438
+ return "elevated capture failed: navigation to m365.cloud.microsoft did not complete — network issue, corp-proxy block, or tenant policy. Check connectivity and retry. If persistent, the elevated commands (list-chats / get-chat / historical-version downloads) will be unavailable.";
1438
1439
  }
1439
- return "elevated token capture timed out — silent SSO against m365.cloud.microsoft did not yield a Bearer within 20s. The persistent browser-profile cookies are likely expired. Run `ask-marcel logout && ask-marcel login` — this now wipes the profile too. (Commands that need this token: list-chats, get-chat, list-chat-members, the historical-version download / convert commands.)";
1440
+ return "elevated token capture timed out — silent SSO against m365.cloud.microsoft did not yield a Bearer within 20s. The persistent browser-profile cookies are likely expired. Run `ask-marcel logout && ask-marcel login` — this now wipes the profile too. (Commands that need this token: list-chats, get-chat, the historical-version download / convert commands.)";
1440
1441
  };
1441
1442
  const recaptureElevated = async () => {
1442
1443
  try {
@@ -1471,6 +1472,8 @@ var createAuthManagerFromApi = (browserAuth, cachePath, browserProfileDir, logge
1471
1472
  logger.info("auth.elevated.cache_hit");
1472
1473
  return ok(validated.value);
1473
1474
  }
1475
+ if (!recaptureSecondaryViaBrowser)
1476
+ return err({ type: "auth_failed", message: failFastSecondaryMessage("Elevated (M365)", "list-chats, get-chat, download-drive-item-version") });
1474
1477
  return recaptureElevatedShared();
1475
1478
  };
1476
1479
  const freshChatsvcaggToken = (cached) => {
@@ -1521,6 +1524,11 @@ var createAuthManagerFromApi = (browserAuth, cachePath, browserProfileDir, logge
1521
1524
  logger.info("auth.chatsvcagg.cache_hit");
1522
1525
  return ok(accessTokenUnsafe(fresh));
1523
1526
  }
1527
+ if (!recaptureSecondaryViaBrowser)
1528
+ return err({
1529
+ type: "auth_failed",
1530
+ message: failFastSecondaryMessage("chatsvcagg (Teams chat)", "list-teams-chats-with-messages, list-teams-chat-messages, get-teams-chat-message, find-chats-with-user")
1531
+ });
1524
1532
  return recaptureChatsvcaggShared();
1525
1533
  };
1526
1534
  const getChatsvcaggRegion = async () => {
@@ -1576,6 +1584,8 @@ var createAuthManagerFromApi = (browserAuth, cachePath, browserProfileDir, logge
1576
1584
  logger.info("auth.ic3.cache_hit");
1577
1585
  return ok(accessTokenUnsafe(fresh));
1578
1586
  }
1587
+ if (!recaptureSecondaryViaBrowser)
1588
+ return err({ type: "auth_failed", message: failFastSecondaryMessage("ic3 (Teams chat history)", "list-teams-chat-history") });
1579
1589
  return recaptureIc3Shared();
1580
1590
  };
1581
1591
  const logout = async () => {
@@ -1633,7 +1643,7 @@ var createAuthManager = (deps) => {
1633
1643
  freshCachedToken: createFreshCachedTokenProbe(fs, deps.cachePath),
1634
1644
  onProgress: stderrProgress
1635
1645
  });
1636
- return createAuthManagerFromApi(browserAuth, deps.cachePath, browserProfileDir, deps.logger, fs, deps.systemBrowserAuth, deps.usePlaywrightFallback, deps.skipSystemBrowser);
1646
+ return createAuthManagerFromApi(browserAuth, deps.cachePath, browserProfileDir, deps.logger, fs, deps.systemBrowserAuth, deps.usePlaywrightFallback, deps.skipSystemBrowser, deps.recaptureSecondaryViaBrowser);
1637
1647
  };
1638
1648
  // src/infra/network-error.ts
1639
1649
  var REQUEST_TIMEOUT_MS = 60000;
@@ -19896,7 +19906,7 @@ __export(exports_list_chat_members, {
19896
19906
  });
19897
19907
  var baseSchema21 = exports_external.object({ chatId: exports_external.string().min(1) });
19898
19908
  var CHAT_MEMBERS_ODATA_KEYS = ["skip", "select", "filter"];
19899
- var inner5 = buildElevatedPickODataListCommand((p) => `/chats/${p.chatId}/members`, baseSchema21, CHAT_MEMBERS_ODATA_KEYS);
19909
+ var inner5 = buildPickODataListCommand((p) => `/chats/${p.chatId}/members`, baseSchema21, CHAT_MEMBERS_ODATA_KEYS);
19900
19910
  var execute42 = async (graph, params) => {
19901
19911
  const result = await inner5.execute(graph, params);
19902
19912
  if (result.ok)
@@ -19931,8 +19941,7 @@ var meta44 = {
19931
19941
  ],
19932
19942
  example: "ask-marcel list-chat-members --chat-id '19:abc...@thread.v2'",
19933
19943
  responseShape: "collection of Microsoft Graph `conversationMember` resources under `value[]`",
19934
- pagination: true,
19935
- needsElevatedToken: true
19944
+ pagination: true
19936
19945
  };
19937
19946
 
19938
19947
  // src/use-cases/commands/list-drive-item-permissions.ts
@@ -20894,8 +20903,13 @@ __export(exports_next_page, {
20894
20903
  execute: () => execute74
20895
20904
  });
20896
20905
  var PREFIX = "https://graph.microsoft.com/v1.0";
20897
- var ELEVATED_PATH_PREFIXES = ["/me/chats", "/chats/"];
20898
- var requiresElevated = (path) => ELEVATED_PATH_PREFIXES.some((prefix) => path.startsWith(prefix));
20906
+ var requiresElevated = (path) => {
20907
+ if (path.startsWith("/me/chats"))
20908
+ return true;
20909
+ if (path.startsWith("/chats/"))
20910
+ return !path.includes("/members");
20911
+ return false;
20912
+ };
20899
20913
  var schema74 = exports_external.object({
20900
20914
  url: exports_external.string().min(1).refine((v) => v.startsWith(`${PREFIX}/`), { message: `must be a Microsoft Graph v1.0 URL starting with ${PREFIX}/` })
20901
20915
  });
@@ -23513,15 +23527,43 @@ var projectMember = (m) => {
23513
23527
  out.userSubType = m.userSubType;
23514
23528
  return out;
23515
23529
  };
23516
- var execute107 = async (graph, params) => {
23517
- const parsed = schema107.safeParse(params);
23518
- if (!parsed.success)
23519
- return err({ type: "validation_error", message: formatZodError(parsed.error) });
23520
- const queryFolded = fold(parsed.data.name);
23521
- const pageSize = parsed.data.pageSize ?? "100";
23522
- const maxPages = Number(parsed.data.maxPages ?? "10");
23523
- const matched = [];
23524
- const seenChatIds = new Set;
23530
+ var isNameResolvable = (m) => {
23531
+ const fields = [m.displayName, m.email, m.userPrincipalName, m.givenName, m.surname];
23532
+ return fields.some((f) => typeof f === "string" && f.trim() !== "");
23533
+ };
23534
+ var bareMemberCount = (members) => members.filter((m) => !isNameResolvable(m)).length;
23535
+ var fromGraphMember = (g) => ({
23536
+ ...g.displayName !== undefined ? { displayName: g.displayName } : {},
23537
+ ...g.email !== undefined ? { email: g.email } : {},
23538
+ ...typeof g.userId === "string" ? { mri: `8:orgid:${g.userId}`, objectId: g.userId } : {}
23539
+ });
23540
+ var toMatchedChat = (chatId, chat, members) => ({
23541
+ chatId,
23542
+ title: chat.title ?? null,
23543
+ chatType: chat.chatType,
23544
+ threadType: chat.threadType,
23545
+ memberCount: (chat.members ?? []).length,
23546
+ ...chat.lastMessage?.composeTime !== undefined ? { lastMessageAt: chat.lastMessage.composeTime } : {},
23547
+ matchedMembers: members.map(projectMember)
23548
+ });
23549
+ var collectChat = (chat, queryFolded, acc) => {
23550
+ if (chat.id === undefined || acc.seen.has(chat.id))
23551
+ return;
23552
+ const members = chat.members ?? [];
23553
+ const hits = members.filter((m) => memberMatches(m, queryFolded));
23554
+ if (hits.length > 0) {
23555
+ acc.seen.add(chat.id);
23556
+ acc.matched.push(toMatchedChat(chat.id, chat, hits));
23557
+ return;
23558
+ }
23559
+ const bareCount = bareMemberCount(members);
23560
+ if (bareCount > 0) {
23561
+ acc.seen.add(chat.id);
23562
+ acc.bareUnmatched.push({ chatId: chat.id, chat, bareCount });
23563
+ }
23564
+ };
23565
+ var scanChatPages = async (graph, queryFolded, pageSize, maxPages) => {
23566
+ const acc = { seen: new Set, matched: [], bareUnmatched: [] };
23525
23567
  let continuationToken;
23526
23568
  let pagesFetched = 0;
23527
23569
  let chatsScanned = 0;
@@ -23536,42 +23578,67 @@ var execute107 = async (graph, params) => {
23536
23578
  const chats = body.chats ?? [];
23537
23579
  pagesFetched += 1;
23538
23580
  chatsScanned += chats.length;
23539
- for (const chat of chats) {
23540
- if (chat.id === undefined || seenChatIds.has(chat.id))
23541
- continue;
23542
- const members = chat.members ?? [];
23543
- const matchedMembers = members.filter((m) => memberMatches(m, queryFolded));
23544
- if (matchedMembers.length === 0)
23545
- continue;
23546
- seenChatIds.add(chat.id);
23547
- matched.push({
23548
- chatId: chat.id,
23549
- title: chat.title ?? null,
23550
- chatType: chat.chatType,
23551
- threadType: chat.threadType,
23552
- memberCount: members.length,
23553
- ...chat.lastMessage?.composeTime !== undefined ? { lastMessageAt: chat.lastMessage.composeTime } : {},
23554
- matchedMembers: matchedMembers.map(projectMember)
23555
- });
23556
- }
23581
+ for (const chat of chats)
23582
+ collectChat(chat, queryFolded, acc);
23557
23583
  if (body.hasMoreData !== true || body.continuationToken === undefined) {
23558
23584
  continuationToken = undefined;
23559
23585
  break;
23560
23586
  }
23561
23587
  continuationToken = body.continuationToken;
23562
23588
  }
23589
+ return ok({ matched: acc.matched, bareUnmatched: acc.bareUnmatched, pagesFetched, chatsScanned, continuationToken });
23590
+ };
23591
+ var sumBareCounts = (bare) => bare.reduce((n, b) => n + b.bareCount, 0);
23592
+ var hydrateMatches = async (graph, chatId, queryFolded) => {
23593
+ const res = await graph.get(`/chats/${chatId}/members`);
23594
+ if (!res.ok)
23595
+ return null;
23596
+ const value = res.value.value ?? [];
23597
+ return value.map(fromGraphMember).filter((m) => memberMatches(m, queryFolded));
23598
+ };
23599
+ var isDirectChat = (chatId) => chatId.endsWith("@unq.gbl.spaces");
23600
+ var hydrateBareDirect = async (graph, queryFolded, bareUnmatched, matched) => {
23601
+ const direct = bareUnmatched.filter((b) => isDirectChat(b.chatId));
23602
+ let unresolvedMemberCount = sumBareCounts(bareUnmatched.filter((b) => !isDirectChat(b.chatId)));
23603
+ const results = await Promise.all(direct.map(async (b) => ({ bare: b, hits: await hydrateMatches(graph, b.chatId, queryFolded) })));
23604
+ for (const { bare, hits } of results) {
23605
+ if (hits === null) {
23606
+ unresolvedMemberCount += bare.bareCount;
23607
+ continue;
23608
+ }
23609
+ if (hits.length > 0)
23610
+ matched.push(toMatchedChat(bare.chatId, bare.chat, hits));
23611
+ }
23612
+ return { chatsHydrated: direct.length, unresolvedMemberCount };
23613
+ };
23614
+ var HINT2 = "No chat member matched by name, but at least one chat has a cross-tenant member the Teams roster left unresolved — an externally-homed counterpart often appears only as a bare object-id. Direct 1:1 chats were deep-probed; members in group/meeting chats were not. Retry searching by their object-id (pass it as `--name <object-id>`), or, if you have the chat URL, read it directly with `get-chat` / `list-teams-chat-messages --chat-id 19:<their-oid>_<your-oid>@unq.gbl.spaces`.";
23615
+ var execute107 = async (graph, params) => {
23616
+ const parsed = schema107.safeParse(params);
23617
+ if (!parsed.success)
23618
+ return err({ type: "validation_error", message: formatZodError(parsed.error) });
23619
+ const queryFolded = fold(parsed.data.name);
23620
+ const pageSize = parsed.data.pageSize ?? "100";
23621
+ const maxPages = Number(parsed.data.maxPages ?? "20");
23622
+ const scan = await scanChatPages(graph, queryFolded, pageSize, maxPages);
23623
+ if (!scan.ok)
23624
+ return scan;
23625
+ const { matched, bareUnmatched, pagesFetched, chatsScanned, continuationToken } = scan.value;
23626
+ const { chatsHydrated, unresolvedMemberCount } = await hydrateBareDirect(graph, queryFolded, bareUnmatched, matched);
23563
23627
  return ok({
23564
23628
  name: parsed.data.name,
23565
23629
  matches: matched,
23566
23630
  matchCount: matched.length,
23567
23631
  pagesFetched,
23568
23632
  chatsScanned,
23633
+ chatsHydrated,
23634
+ unresolvedMemberCount,
23569
23635
  hasMore: continuationToken !== undefined,
23570
- nextContinuationToken: continuationToken
23636
+ nextContinuationToken: continuationToken,
23637
+ ...matched.length === 0 && unresolvedMemberCount > 0 ? { hint: HINT2 } : {}
23571
23638
  });
23572
23639
  };
23573
23640
  var meta109 = {
23574
- summary: 'Find every Microsoft Teams chat that includes a member matching `--name` (substring search across display-name, email, given-name, surname, MRI, and object-id). Both sides are Unicode-folded (NFD + combining-mark strip) and lowercased before comparison, so `--name Jane` matches `Jane DOE` AND `jane.doe@example.com` AND `JANE` — important because a dual-identity user often carries the accented display-name on one identity and the un-accented email on the other. Walks the paginated chat-list substrate up to `--max-pages` and returns matching chats with their `matchedMembers[]`. Collapses the canonical "all conversations with person X" workflow into a single call AND surfaces dual-identity people (e.g. someone with both an org MRI and a guest-tenant MRI). **Best-effort, may break on Microsoft client updates** — the chat substrate is not in the public Microsoft Graph API.',
23641
+ summary: 'Find every Microsoft Teams chat that includes a member matching `--name` (substring search across display-name, email, given-name, surname, MRI, and object-id). Both sides are Unicode-folded (NFD + combining-mark strip) and lowercased before comparison, so `--name Jane` matches `Jane DOE` AND `jane.doe@example.com` AND `JANE` — important because a dual-identity user often carries the accented display-name on one identity and the un-accented email on the other. Walks the paginated chat-list substrate up to `--max-pages` and returns matching chats with their `matchedMembers[]`. Collapses the canonical "all conversations with person X" workflow into a single call AND surfaces dual-identity people (e.g. someone with both an org MRI and a guest-tenant MRI). Cross-tenant resolution: the summary roster returns externally-homed counterparts as a bare object-id (no name/email), which a name search cannot match; for every bare DIRECT (1:1) chat the command hydrates the roster via the per-chat members endpoint and re-matches — so an external counterpart who is bare in your 1:1 is still found, even when they were already resolved in some meeting (the dual-identity case). Bare members in group/meeting chats are not deep-probed; when nothing matches and such members exist it returns a `hint` plus `unresolvedMemberCount` rather than a confident empty result. **Best-effort, may break on Microsoft client updates** — the chat substrate is not in the public Microsoft Graph API.',
23575
23642
  category: "chats",
23576
23643
  needsSubstrateToken: true,
23577
23644
  graphMethod: "GET",
@@ -23588,7 +23655,7 @@ var meta109 = {
23588
23655
  name: "max-pages",
23589
23656
  key: "maxPages",
23590
23657
  required: false,
23591
- description: "Safety cap on the chat-list walk (positive integer; default 10). Each page returns up to `--page-size` chats. Raise carefully on busy accounts every page is one HTTP round-trip."
23658
+ description: "Safety cap on the chat-list walk (positive integer; default 20). The walk stops early once the substrate reports no more pages, so the cap only bites on accounts with more chats than `--max-pages × --page-size`; raise it (and watch `hasMore`) if a known chat is missed. Each page is one HTTP round-trip."
23592
23659
  },
23593
23660
  {
23594
23661
  name: "page-size",
@@ -23598,7 +23665,7 @@ var meta109 = {
23598
23665
  }
23599
23666
  ],
23600
23667
  example: "ask-marcel find-chats-with-user --name 'Jane DOE'",
23601
- responseShape: "`{ name, matches: [{ chatId, title, chatType, threadType, memberCount, lastMessageAt?, matchedMembers: [{ mri, displayName, email, userSubType }] }], matchCount, pagesFetched, chatsScanned, hasMore, nextContinuationToken? }`. `matchedMembers` always carries the matching entries' identifying fields — pass `chatId` into `list-teams-chat-history` to read message bodies. `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`).",
23668
+ 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`).",
23602
23669
  stability: "experimental"
23603
23670
  };
23604
23671
 
@@ -26018,7 +26085,7 @@ var buildDeps = (config2 = {}) => {
26018
26085
  const processRunner = config2.processRunner ?? defaultProcessRunner();
26019
26086
  const logger = createWinstonLogger({ logLevel });
26020
26087
  const makeAuth = config2.createAuth ?? createAuthManager;
26021
- const auth = makeAuth({ cachePath, logger, fs, skipSystemBrowser: true, usePlaywrightFallback: true });
26088
+ const auth = makeAuth({ cachePath, logger, fs, skipSystemBrowser: true, usePlaywrightFallback: true, recaptureSecondaryViaBrowser: false });
26022
26089
  const graph = createGraphClient(auth);
26023
26090
  const makeLoginAuth = ({ useExtension }) => makeAuth({ cachePath, logger, fs, skipSystemBrowser: !useExtension, usePlaywrightFallback: !useExtension });
26024
26091
  return { logger, auth, graph, processRunner, fs, makeLoginAuth };
@@ -81,7 +81,7 @@ type SystemBrowserAuthFn = () => Promise<Result<{
81
81
  type: string;
82
82
  message: string;
83
83
  }>>;
84
- declare const createAuthManagerFromApi: (browserAuth: BrowserAuth, cachePath: string, browserProfileDir: string, logger: Logger, fs: FileSystem, systemBrowserAuthFn?: SystemBrowserAuthFn, usePlaywrightFallback?: boolean, skipSystemBrowser?: boolean) => AuthManager;
84
+ declare const createAuthManagerFromApi: (browserAuth: BrowserAuth, cachePath: string, browserProfileDir: string, logger: Logger, fs: FileSystem, systemBrowserAuthFn?: SystemBrowserAuthFn, usePlaywrightFallback?: boolean, skipSystemBrowser?: boolean, recaptureSecondaryViaBrowser?: boolean) => AuthManager;
85
85
  /**
86
86
  * QA-010: probe the token cache for a fresh access token. Handed to the
87
87
  * browser capture so its poll loop can short-circuit the multi-minute dance
@@ -99,6 +99,7 @@ declare const createAuthManager: (deps: {
99
99
  systemBrowserAuth?: SystemBrowserAuthFn;
100
100
  usePlaywrightFallback?: boolean;
101
101
  skipSystemBrowser?: boolean;
102
+ recaptureSecondaryViaBrowser?: boolean;
102
103
  }) => AuthManager;
103
104
  export { createAuthManager, createAuthManagerFromApi, createFreshCachedTokenProbe, stderrProgress };
104
105
  export type { AuthError, AuthManager, ElevatedOutcome, SystemBrowserAuthFn };
package/docs/COMMANDS.md CHANGED
@@ -223,7 +223,7 @@ For everything else:
223
223
 
224
224
  | Command | Description | Required params | Graph endpoint |
225
225
  |---------|-------------|-----------------|----------------|
226
- | `find-chats-with-user` | Find every Microsoft Teams chat that includes a member matching `--name` (substring search across display-name, email, given-name, surname, MRI, and object-id). Both sides are Unicode-folded (NFD + combining-mark strip) and lowercased before comparison, so `--name Jane` matches `Jane DOE` AND `jane.doe@example.com` AND `JANE` — important because a dual-identity user often carries the accented display-name on one identity and the un-accented email on the other. Walks the paginated chat-list substrate up to `--max-pages` and returns matching chats with their `matchedMembers[]`. Collapses the canonical "all conversations with person X" workflow into a single call AND surfaces dual-identity people (e.g. someone with both an org MRI and a guest-tenant MRI). **Best-effort, may break on Microsoft client updates** — the chat substrate is not in the public Microsoft Graph API. | `--name`, `--max-pages`, `--page-size` | `GET https://teams.microsoft.com/api/csa/{region}/api/v3/teams/users/me/chats` |
226
+ | `find-chats-with-user` | Find every Microsoft Teams chat that includes a member matching `--name` (substring search across display-name, email, given-name, surname, MRI, and object-id). Both sides are Unicode-folded (NFD + combining-mark strip) and lowercased before comparison, so `--name Jane` matches `Jane DOE` AND `jane.doe@example.com` AND `JANE` — important because a dual-identity user often carries the accented display-name on one identity and the un-accented email on the other. Walks the paginated chat-list substrate up to `--max-pages` and returns matching chats with their `matchedMembers[]`. Collapses the canonical "all conversations with person X" workflow into a single call AND surfaces dual-identity people (e.g. someone with both an org MRI and a guest-tenant MRI). Cross-tenant resolution: the summary roster returns externally-homed counterparts as a bare object-id (no name/email), which a name search cannot match; for every bare DIRECT (1:1) chat the command hydrates the roster via the per-chat members endpoint and re-matches — so an external counterpart who is bare in your 1:1 is still found, even when they were already resolved in some meeting (the dual-identity case). Bare members in group/meeting chats are not deep-probed; when nothing matches and such members exist it returns a `hint` plus `unresolvedMemberCount` rather than a confident empty result. **Best-effort, may break on Microsoft client updates** — the chat substrate is not in the public Microsoft Graph API. | `--name`, `--max-pages`, `--page-size` | `GET https://teams.microsoft.com/api/csa/{region}/api/v3/teams/users/me/chats` |
227
227
  | `get-chat` | Return metadata for a single Microsoft Teams chat (1:1, group, or meeting). The CLI ships a slim default `--select=id,topic,chatType,createdDateTime,lastUpdatedDateTime`; pass `--select id,topic,webUrl,onlineMeetingInfo` (or any other comma-separated field list) to widen. Pass `--expand members` to inline membership. Returns metadata only — not the messages (which need `Chat.Read*`). Requires the M365ChatClient elevated token captured at login (the basic Teams web client token lacks `Chat.ReadBasic`). | `--chat-id`, `--select`, `--expand` | `GET /chats/{chat-id}` |
228
228
  | `get-teams-chat-message` | Return a single Microsoft Teams chat message by its id via the chat substrate. Uses the chatsvcagg-audience bearer captured at login (same identity as the basic Teams token, different audience). **Best-effort, may break on Microsoft client updates** — the chat substrate is not in the public Microsoft Graph API. Source the chat-id + message-id via `list-teams-chats-with-messages` or `list-teams-chat-messages`. | `--chat-id`, `--message-id` | `GET https://teams.microsoft.com/api/csa/{region}/api/v1/chats/{chat-id}/messages/{message-id}` |
229
229
  | `list-chat-members` | List the members of a single Microsoft Teams chat. Graph rejects `$top` / `$orderby` / `$expand` on this endpoint, so the CLI advertises only the subset Graph honours (`--skip`, `--select`, `--filter`). | `--chat-id`, `--skip`, `--select`, `--filter` | `GET /chats/{chat-id}/members` |
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "package": "ask-marcel-office-cli",
3
- "version": "1.5.0",
4
- "generatedAt": "2026-06-16T16:14:42.738Z",
3
+ "version": "1.5.2",
4
+ "generatedAt": "2026-06-23T11:35:14.255Z",
5
5
  "commands": [
6
6
  {
7
7
  "name": "convert-calendar-event-attachment-to-markdown",
@@ -666,7 +666,7 @@
666
666
  },
667
667
  {
668
668
  "name": "find-chats-with-user",
669
- "summary": "Find every Microsoft Teams chat that includes a member matching `--name` (substring search across display-name, email, given-name, surname, MRI, and object-id). Both sides are Unicode-folded (NFD + combining-mark strip) and lowercased before comparison, so `--name Jane` matches `Jane DOE` AND `jane.doe@example.com` AND `JANE` — important because a dual-identity user often carries the accented display-name on one identity and the un-accented email on the other. Walks the paginated chat-list substrate up to `--max-pages` and returns matching chats with their `matchedMembers[]`. Collapses the canonical \"all conversations with person X\" workflow into a single call AND surfaces dual-identity people (e.g. someone with both an org MRI and a guest-tenant MRI). **Best-effort, may break on Microsoft client updates** — the chat substrate is not in the public Microsoft Graph API.",
669
+ "summary": "Find every Microsoft Teams chat that includes a member matching `--name` (substring search across display-name, email, given-name, surname, MRI, and object-id). Both sides are Unicode-folded (NFD + combining-mark strip) and lowercased before comparison, so `--name Jane` matches `Jane DOE` AND `jane.doe@example.com` AND `JANE` — important because a dual-identity user often carries the accented display-name on one identity and the un-accented email on the other. Walks the paginated chat-list substrate up to `--max-pages` and returns matching chats with their `matchedMembers[]`. Collapses the canonical \"all conversations with person X\" workflow into a single call AND surfaces dual-identity people (e.g. someone with both an org MRI and a guest-tenant MRI). Cross-tenant resolution: the summary roster returns externally-homed counterparts as a bare object-id (no name/email), which a name search cannot match; for every bare DIRECT (1:1) chat the command hydrates the roster via the per-chat members endpoint and re-matches — so an external counterpart who is bare in your 1:1 is still found, even when they were already resolved in some meeting (the dual-identity case). Bare members in group/meeting chats are not deep-probed; when nothing matches and such members exist it returns a `hint` plus `unresolvedMemberCount` rather than a confident empty result. **Best-effort, may break on Microsoft client updates** — the chat substrate is not in the public Microsoft Graph API.",
670
670
  "category": "chats",
671
671
  "graphMethod": "GET",
672
672
  "graphPathTemplate": "https://teams.microsoft.com/api/csa/{region}/api/v3/teams/users/me/chats",
@@ -682,7 +682,7 @@
682
682
  "name": "max-pages",
683
683
  "key": "maxPages",
684
684
  "required": false,
685
- "description": "Safety cap on the chat-list walk (positive integer; default 10). Each page returns up to `--page-size` chats. Raise carefully on busy accounts every page is one HTTP round-trip."
685
+ "description": "Safety cap on the chat-list walk (positive integer; default 20). The walk stops early once the substrate reports no more pages, so the cap only bites on accounts with more chats than `--max-pages × --page-size`; raise it (and watch `hasMore`) if a known chat is missed. Each page is one HTTP round-trip."
686
686
  },
687
687
  {
688
688
  "name": "page-size",
@@ -692,7 +692,7 @@
692
692
  }
693
693
  ],
694
694
  "example": "ask-marcel find-chats-with-user --name 'Jane DOE'",
695
- "responseShape": "`{ name, matches: [{ chatId, title, chatType, threadType, memberCount, lastMessageAt?, matchedMembers: [{ mri, displayName, email, userSubType }] }], matchCount, pagesFetched, chatsScanned, hasMore, nextContinuationToken? }`. `matchedMembers` always carries the matching entries' identifying fields — pass `chatId` into `list-teams-chat-history` to read message bodies. `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`).",
695
+ "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`).",
696
696
  "needsSubstrateToken": true,
697
697
  "stability": "experimental"
698
698
  },
@@ -2987,8 +2987,7 @@
2987
2987
  ],
2988
2988
  "example": "ask-marcel list-chat-members --chat-id '19:abc...@thread.v2'",
2989
2989
  "responseShape": "collection of Microsoft Graph `conversationMember` resources under `value[]`",
2990
- "pagination": true,
2991
- "needsElevatedToken": true
2990
+ "pagination": true
2992
2991
  },
2993
2992
  {
2994
2993
  "name": "list-chats",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ask-marcel-office-cli",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
4
4
  "description": "Microsoft Graph CLI + library \u2014 typed Bun/TypeScript wrapper around 150+ Graph operations (read + on-the-fly PDF/markdown conversion + federated Microsoft Search) reachable from a Teams browser-OAuth token.",
5
5
  "license": "MIT",
6
6
  "author": "Vincent Delacourt <vincent.delacourt@adama-development.com>",