mgraphctl 0.1.0__py3-none-any.whl
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.
Potentially problematic release.
This version of mgraphctl might be problematic. Click here for more details.
- mgraphctl/__init__.py +3 -0
- mgraphctl/__main__.py +3 -0
- mgraphctl/auth.py +395 -0
- mgraphctl/cli.py +259 -0
- mgraphctl/commands/__init__.py +71 -0
- mgraphctl/commands/api.py +164 -0
- mgraphctl/commands/calendar.py +491 -0
- mgraphctl/commands/chats.py +329 -0
- mgraphctl/commands/config_cmd.py +140 -0
- mgraphctl/commands/groups.py +65 -0
- mgraphctl/commands/mail.py +705 -0
- mgraphctl/commands/mailbox.py +176 -0
- mgraphctl/commands/meetings.py +294 -0
- mgraphctl/commands/onedrive.py +351 -0
- mgraphctl/commands/onenote.py +196 -0
- mgraphctl/commands/org.py +102 -0
- mgraphctl/commands/people.py +201 -0
- mgraphctl/commands/planner.py +268 -0
- mgraphctl/commands/presence.py +102 -0
- mgraphctl/commands/search.py +75 -0
- mgraphctl/commands/sharepoint.py +304 -0
- mgraphctl/commands/teams.py +235 -0
- mgraphctl/commands/todo.py +253 -0
- mgraphctl/commands/top.py +284 -0
- mgraphctl/config.py +385 -0
- mgraphctl/errors.py +276 -0
- mgraphctl/fixtures.py +149 -0
- mgraphctl/graph/__init__.py +1 -0
- mgraphctl/graph/calendar.py +313 -0
- mgraphctl/graph/chats.py +447 -0
- mgraphctl/graph/files.py +269 -0
- mgraphctl/graph/groups.py +55 -0
- mgraphctl/graph/mail.py +663 -0
- mgraphctl/graph/mailbox.py +89 -0
- mgraphctl/graph/meetings.py +269 -0
- mgraphctl/graph/onedrive.py +35 -0
- mgraphctl/graph/onenote.py +138 -0
- mgraphctl/graph/org.py +86 -0
- mgraphctl/graph/people.py +83 -0
- mgraphctl/graph/planner.py +415 -0
- mgraphctl/graph/presence.py +89 -0
- mgraphctl/graph/search.py +178 -0
- mgraphctl/graph/sharepoint.py +294 -0
- mgraphctl/graph/teams.py +240 -0
- mgraphctl/graph/todo.py +199 -0
- mgraphctl/graph/users.py +85 -0
- mgraphctl/html.py +127 -0
- mgraphctl/http.py +843 -0
- mgraphctl/odata.py +72 -0
- mgraphctl/render.py +627 -0
- mgraphctl/resolve.py +111 -0
- mgraphctl/token_store.py +196 -0
- mgraphctl-0.1.0.dist-info/METADATA +226 -0
- mgraphctl-0.1.0.dist-info/RECORD +57 -0
- mgraphctl-0.1.0.dist-info/WHEEL +4 -0
- mgraphctl-0.1.0.dist-info/entry_points.txt +3 -0
- mgraphctl-0.1.0.dist-info/licenses/LICENSE +21 -0
mgraphctl/resolve.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Generic name-versus-id resolution machinery (spec §6.6). No Graph calls live here."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
from mgraphctl.errors import AmbiguousError, NotFoundError
|
|
9
|
+
|
|
10
|
+
GUID_RE = re.compile(
|
|
11
|
+
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
WELL_KNOWN_FOLDERS = frozenset(
|
|
15
|
+
[
|
|
16
|
+
"inbox",
|
|
17
|
+
"drafts",
|
|
18
|
+
"sentitems",
|
|
19
|
+
"deleteditems",
|
|
20
|
+
"junkemail",
|
|
21
|
+
"archive",
|
|
22
|
+
"outbox",
|
|
23
|
+
"clutter",
|
|
24
|
+
"conversationhistory",
|
|
25
|
+
"msgfolderroot",
|
|
26
|
+
]
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_DRIVE_ITEM_RE = re.compile(r"[A-Za-z0-9!]{20,}")
|
|
30
|
+
_PLANNER_RE = re.compile(r"[A-Za-z0-9_-]{28}")
|
|
31
|
+
_ONENOTE_PREFIX_RE = re.compile(r"^\d-")
|
|
32
|
+
|
|
33
|
+
# An opaque Outlook id is long and unbroken; a folder or calendar people typed has spaces.
|
|
34
|
+
_OPAQUE_MIN = 40
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def split_id_prefix(value: str) -> tuple[str, bool]:
|
|
38
|
+
"""Strip an explicit `id:` prefix: `("x", True)` for `"id:x"`, else `(value, False)`."""
|
|
39
|
+
if value.startswith("id:"):
|
|
40
|
+
return value[3:], True
|
|
41
|
+
return value, False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_opaque(value: str) -> bool:
|
|
45
|
+
return len(value) >= _OPAQUE_MIN and " " not in value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _matches_shape(value: str, kind: str) -> bool:
|
|
49
|
+
"""Whether an unprefixed `value` has the id shape of `kind` (the §6.6 table)."""
|
|
50
|
+
match kind:
|
|
51
|
+
case "guid" | "team" | "group":
|
|
52
|
+
return bool(GUID_RE.match(value))
|
|
53
|
+
case "mail_folder":
|
|
54
|
+
return value.lower() in WELL_KNOWN_FOLDERS or _is_opaque(value)
|
|
55
|
+
case "channel" | "chat":
|
|
56
|
+
return value.startswith("19:")
|
|
57
|
+
case "user":
|
|
58
|
+
return bool(GUID_RE.match(value)) or "@" in value or value == "me"
|
|
59
|
+
case "site":
|
|
60
|
+
return value.startswith(("https://", "http://")) or ":/" in value or "," in value
|
|
61
|
+
case "drive":
|
|
62
|
+
return value.startswith("b!")
|
|
63
|
+
case "drive_item":
|
|
64
|
+
return bool(_DRIVE_ITEM_RE.fullmatch(value)) and "." not in value
|
|
65
|
+
case "onenote":
|
|
66
|
+
return "!" in value or bool(_ONENOTE_PREFIX_RE.match(value))
|
|
67
|
+
case "planner":
|
|
68
|
+
return bool(_PLANNER_RE.fullmatch(value))
|
|
69
|
+
case "todo_list":
|
|
70
|
+
return value.startswith(("AQMk", "AAMk")) or len(value) >= _OPAQUE_MIN
|
|
71
|
+
case "calendar":
|
|
72
|
+
return _is_opaque(value)
|
|
73
|
+
case _:
|
|
74
|
+
raise ValueError(f"unknown id kind {kind!r}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def looks_like_id(value: str, kind: str) -> bool:
|
|
78
|
+
"""Whether `value` is already an id of `kind`, rather than a name to look up (§6.6).
|
|
79
|
+
|
|
80
|
+
An explicit `id:` prefix forces the id reading for every kind; callers strip it with
|
|
81
|
+
`split_id_prefix`. The shape check still runs first, so an unknown kind is still a
|
|
82
|
+
programming error rather than a silent True.
|
|
83
|
+
"""
|
|
84
|
+
bare, forced = split_id_prefix(value)
|
|
85
|
+
shaped = _matches_shape(bare, kind)
|
|
86
|
+
return forced or shaped
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _value_of(candidate: dict, key: str | Callable[[dict], str | None]) -> str | None:
|
|
90
|
+
return key(candidate) if callable(key) else candidate.get(key)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def pick_unique(
|
|
94
|
+
candidates: list[dict],
|
|
95
|
+
key: str | Callable[[dict], str | None],
|
|
96
|
+
needle: str,
|
|
97
|
+
*,
|
|
98
|
+
what: str,
|
|
99
|
+
) -> dict:
|
|
100
|
+
"""The one candidate whose `key` equals `needle`, compared case-insensitively (§6.6)."""
|
|
101
|
+
folded = needle.casefold()
|
|
102
|
+
matches = [c for c in candidates if (_value_of(c, key) or "").casefold() == folded]
|
|
103
|
+
if not matches:
|
|
104
|
+
raise NotFoundError("NOT_FOUND", f"{what} {needle!r} not found")
|
|
105
|
+
if len(matches) > 1:
|
|
106
|
+
raise AmbiguousError(
|
|
107
|
+
"AMBIGUOUS",
|
|
108
|
+
f"{what} {needle!r} matches {len(matches)} items",
|
|
109
|
+
candidates=[(c.get("id", ""), _value_of(c, key) or "") for c in matches],
|
|
110
|
+
)
|
|
111
|
+
return matches[0]
|
mgraphctl/token_store.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Where the msal token cache lives: an OS keychain item via `keyring`, or the 0600 file.
|
|
2
|
+
|
|
3
|
+
The store holds one opaque string, `msal.SerializableTokenCache.serialize()`, and never parses
|
|
4
|
+
it. Keyring calls let every exception through; `auth` decides whether to fall back to the file.
|
|
5
|
+
Keychain writes are last-writer-wins, the same as the file's `os.replace`; no lock is taken.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from mgraphctl import config
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("mgraphctl.token_store")
|
|
21
|
+
|
|
22
|
+
SERVICE = "mgraphctl"
|
|
23
|
+
KEYRING_LABEL = f"OS keychain ({SERVICE})"
|
|
24
|
+
# The Windows Credential Locker caps one secret at 2560 UTF-16 bytes and keyring does not split
|
|
25
|
+
# for us; macOS and Secret Service take the whole cache in one item. None means "never split".
|
|
26
|
+
CHUNK_CHARS: int | None = 1000 if sys.platform == "win32" else None
|
|
27
|
+
_CHUNKED = "mgraphctl-chunks:"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Store:
|
|
32
|
+
kind: str # "keyring" or "file"
|
|
33
|
+
# The file backend's path. For the keyring it is the item's account name, so two cache
|
|
34
|
+
# paths stay two sign-ins, and the file an earlier version left behind to import.
|
|
35
|
+
path: Path
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def account(self) -> str:
|
|
39
|
+
return str(self.path)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def label(self) -> str:
|
|
43
|
+
"""What `login`/`status`/`logout` print after `Cache:`."""
|
|
44
|
+
return KEYRING_LABEL if self.kind == "keyring" else str(self.path)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def resolve(s: config.Settings) -> Store:
|
|
48
|
+
"""The store `token_store` selects; `auto` asks keyring whether a real backend exists."""
|
|
49
|
+
if s.token_store == "file":
|
|
50
|
+
return Store("file", s.token_cache)
|
|
51
|
+
if s.token_store == "keyring" or keyring_usable():
|
|
52
|
+
return Store("keyring", s.token_cache)
|
|
53
|
+
return Store("file", s.token_cache)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def keyring_usable() -> bool:
|
|
57
|
+
"""Whether keyring has a real backend. Never touches the keychain itself."""
|
|
58
|
+
try:
|
|
59
|
+
import keyring
|
|
60
|
+
|
|
61
|
+
backend = keyring.get_keyring()
|
|
62
|
+
except Exception as exc:
|
|
63
|
+
log.debug("keyring unavailable: %s", exc)
|
|
64
|
+
return False
|
|
65
|
+
module = type(backend).__module__ or ""
|
|
66
|
+
log.debug("keyring backend: %s.%s", module, type(backend).__name__)
|
|
67
|
+
# `fail` is the placeholder when no store exists; `null` is what `keyring disable` leaves,
|
|
68
|
+
# and it discards writes without an error.
|
|
69
|
+
return "fail" not in module and "null" not in module
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def read(store: Store) -> str | None:
|
|
73
|
+
return _read_keyring(store.account) if store.kind == "keyring" else read_file(store.path)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def write(store: Store, text: str) -> None:
|
|
77
|
+
if store.kind == "keyring":
|
|
78
|
+
_write_keyring(store.account, text)
|
|
79
|
+
else:
|
|
80
|
+
write_file(store.path, text)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def clear(store: Store) -> bool:
|
|
84
|
+
"""Remove the stored cache; True when there was one."""
|
|
85
|
+
return _clear_keyring(store.account) if store.kind == "keyring" else clear_file(store.path)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# --------------------------------------------------------------------------- file backend
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def read_file(path: Path) -> str | None:
|
|
92
|
+
try:
|
|
93
|
+
return path.read_text()
|
|
94
|
+
except OSError as exc:
|
|
95
|
+
log.debug("could not read the token cache %s: %s", path, exc)
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def write_file(path: Path, text: str) -> None:
|
|
100
|
+
"""Atomic 0600 write into a 0700 directory. Raises OSError."""
|
|
101
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
# A directory we do not own cannot be chmod-ed, but the 0600 file still goes in it.
|
|
103
|
+
with contextlib.suppress(OSError):
|
|
104
|
+
path.parent.chmod(0o700)
|
|
105
|
+
# A unique temp file in the same directory: two concurrent invocations must not write
|
|
106
|
+
# through one another's half-finished file before os.replace lands.
|
|
107
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f"{path.name}.", suffix=".tmp")
|
|
108
|
+
try:
|
|
109
|
+
try:
|
|
110
|
+
os.fchmod(fd, 0o600)
|
|
111
|
+
os.write(fd, text.encode())
|
|
112
|
+
finally:
|
|
113
|
+
os.close(fd)
|
|
114
|
+
os.replace(tmp, path)
|
|
115
|
+
except OSError:
|
|
116
|
+
with contextlib.suppress(OSError):
|
|
117
|
+
os.unlink(tmp)
|
|
118
|
+
raise
|
|
119
|
+
path.chmod(0o600)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def clear_file(path: Path) -> bool:
|
|
123
|
+
if not path.exists():
|
|
124
|
+
return False
|
|
125
|
+
path.unlink()
|
|
126
|
+
return True
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# --------------------------------------------------------------------------- keyring backend
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _chunk_account(account: str, i: int) -> str:
|
|
133
|
+
return f"{account}#{i}"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _read_keyring(account: str) -> str | None:
|
|
137
|
+
import keyring
|
|
138
|
+
|
|
139
|
+
head = keyring.get_password(SERVICE, account)
|
|
140
|
+
if head is None or not head.startswith(_CHUNKED):
|
|
141
|
+
return head
|
|
142
|
+
parts = []
|
|
143
|
+
for i in range(int(head[len(_CHUNKED) :])):
|
|
144
|
+
part = keyring.get_password(SERVICE, _chunk_account(account, i))
|
|
145
|
+
if part is None:
|
|
146
|
+
log.debug("token cache chunk %d is missing from the keychain", i)
|
|
147
|
+
return None
|
|
148
|
+
parts.append(part)
|
|
149
|
+
return "".join(parts)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _write_keyring(account: str, text: str) -> None:
|
|
153
|
+
import keyring
|
|
154
|
+
|
|
155
|
+
limit = CHUNK_CHARS
|
|
156
|
+
if limit is None or len(text) <= limit:
|
|
157
|
+
keyring.set_password(SERVICE, account, text)
|
|
158
|
+
stale_from = 0
|
|
159
|
+
else:
|
|
160
|
+
pieces = [text[i : i + limit] for i in range(0, len(text), limit)]
|
|
161
|
+
for i, piece in enumerate(pieces):
|
|
162
|
+
keyring.set_password(SERVICE, _chunk_account(account, i), piece)
|
|
163
|
+
# The head goes last: a reader sees either the previous complete set or this one.
|
|
164
|
+
keyring.set_password(SERVICE, account, f"{_CHUNKED}{len(pieces)}")
|
|
165
|
+
stale_from = len(pieces)
|
|
166
|
+
_delete_chunks_from(account, stale_from)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _delete_chunks_from(account: str, start: int) -> None:
|
|
170
|
+
"""Drop chunk items from `start` up, left behind by a longer earlier write."""
|
|
171
|
+
import keyring
|
|
172
|
+
|
|
173
|
+
i = start
|
|
174
|
+
while keyring.get_password(SERVICE, _chunk_account(account, i)) is not None:
|
|
175
|
+
_delete_item(_chunk_account(account, i))
|
|
176
|
+
i += 1
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _delete_item(account: str) -> None:
|
|
180
|
+
import keyring
|
|
181
|
+
from keyring.errors import PasswordDeleteError
|
|
182
|
+
|
|
183
|
+
with contextlib.suppress(PasswordDeleteError):
|
|
184
|
+
keyring.delete_password(SERVICE, account)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _clear_keyring(account: str) -> bool:
|
|
188
|
+
import keyring
|
|
189
|
+
|
|
190
|
+
head = keyring.get_password(SERVICE, account)
|
|
191
|
+
if head is None:
|
|
192
|
+
return False
|
|
193
|
+
if head.startswith(_CHUNKED):
|
|
194
|
+
_delete_chunks_from(account, 0)
|
|
195
|
+
_delete_item(account)
|
|
196
|
+
return True
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mgraphctl
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Microsoft Graph CLI behind the mgraphctl Claude Code skill
|
|
5
|
+
Keywords: microsoft-graph,microsoft-365,outlook,teams,sharepoint,onedrive,cli
|
|
6
|
+
Author: Sviatoslav Sviridov
|
|
7
|
+
Author-email: Sviatoslav Sviridov <sviridov@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Communications :: Email
|
|
20
|
+
Classifier: Topic :: Office/Business
|
|
21
|
+
Classifier: Topic :: Utilities
|
|
22
|
+
Requires-Dist: msal>=1.38,<2
|
|
23
|
+
Requires-Dist: httpx>=0.28,<1
|
|
24
|
+
Requires-Dist: typer>=0.27,<1
|
|
25
|
+
Requires-Dist: markdownify>=1.2,<2
|
|
26
|
+
Requires-Dist: keyring>=24,<26
|
|
27
|
+
Requires-Dist: tzdata ; sys_platform == 'win32'
|
|
28
|
+
Requires-Python: >=3.11, <3.14
|
|
29
|
+
Project-URL: Homepage, https://github.com/svd/mgraphctl
|
|
30
|
+
Project-URL: Source, https://github.com/svd/mgraphctl
|
|
31
|
+
Project-URL: Changelog, https://github.com/svd/mgraphctl/blob/main/CHANGELOG.md
|
|
32
|
+
Project-URL: Issues, https://github.com/svd/mgraphctl/issues
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# mgraphctl
|
|
36
|
+
|
|
37
|
+
Microsoft 365 access from Claude Code through the Microsoft Graph API: Outlook mail and
|
|
38
|
+
calendar, Teams chats and channel messages, presence, meetings and transcripts, SharePoint,
|
|
39
|
+
OneDrive, OneNote, Planner, Microsoft To Do, AI meeting insights, contacts, and the org chart.
|
|
40
|
+
|
|
41
|
+
The plugin ships a single skill, `mgraphctl`. It is a Python re-implementation of the `msgraph`
|
|
42
|
+
skill, run with `uv`, with a noun-verb command line (`mail list`, `calendar create`, `chats dm`,
|
|
43
|
+
…) instead of Node's flag-driven modes. It is an original design for this repository — not a
|
|
44
|
+
port of, and not tracking, any upstream project — and it extends the Node skill's coverage with
|
|
45
|
+
mail triage, mailbox settings and automatic replies, scheduling (`getSchedule`,
|
|
46
|
+
`findMeetingTimes`), find-or-create Teams DMs, presence, SharePoint lists and uploads, OneDrive
|
|
47
|
+
writes and sharing links, a unified `search` command, and a raw `api` escape hatch. Every write
|
|
48
|
+
verb supports `--dry-run`, and no data command ever opens a browser.
|
|
49
|
+
|
|
50
|
+
## Install
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
claude plugin marketplace add svd/mgraphctl
|
|
54
|
+
claude plugin install mgraphctl@mgraphctl
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The CLI is also on PyPI for use outside Claude Code, under the same version as the plugin:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
uv tool install mgraphctl # or: pipx install mgraphctl / uvx mgraphctl status
|
|
61
|
+
mgraphctl status
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Prerequisite: uv
|
|
65
|
+
|
|
66
|
+
The only prerequisite is [`uv`](https://docs.astral.sh/uv/). On first use in a given environment,
|
|
67
|
+
the plugin's shim script creates a Python virtual environment and installs the CLI's dependencies
|
|
68
|
+
automatically — this takes about 10-40 seconds once; every run after that starts immediately.
|
|
69
|
+
Nothing else needs to be installed by hand.
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh # macOS / Linux
|
|
73
|
+
brew install uv # macOS, via Homebrew
|
|
74
|
+
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" # Windows
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## First run and login
|
|
78
|
+
|
|
79
|
+
mgraphctl signs in as an Entra application of your own; there is no shared one. Before the
|
|
80
|
+
first login, register a public-client application in your tenant (or ask an administrator for
|
|
81
|
+
the id of one) and tell mgraphctl its client id, once:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
${CLAUDE_PLUGIN_ROOT}/mgraphctl config set client_id <application (client) id>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`MGRAPHCTL_CLIENT_ID` in the environment works too. Until one is set, `login` and `status` stop
|
|
88
|
+
with `error[CONFIG]: client_id is not set` rather than an Entra error.
|
|
89
|
+
|
|
90
|
+
Check status first — it never opens a browser and always says what to do next:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
${CLAUDE_PLUGIN_ROOT}/mgraphctl status
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Signing in is interactive, so run it yourself in your own terminal rather than asking Claude to
|
|
97
|
+
run it for you:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
${CLAUDE_PLUGIN_ROOT}/mgraphctl login
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
By default `login` opens your system browser for a Microsoft sign-in page. On a host with no
|
|
104
|
+
browser (for example over SSH), use the device-code flow instead, which prints a URL and a code
|
|
105
|
+
to enter on another device:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
${CLAUDE_PLUGIN_ROOT}/mgraphctl login --device-code
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
On Windows, or wherever the bash shim cannot run, invoke `uv` directly:
|
|
112
|
+
|
|
113
|
+
```bat
|
|
114
|
+
set UV_PROJECT_ENVIRONMENT=%LOCALAPPDATA%\mgraphctl\venv
|
|
115
|
+
uv run --project <plugin> --frozen --no-dev mgraphctl login
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Every other command reads the cached sign-in silently and never opens a browser or a device-code
|
|
119
|
+
prompt — a missing or expired token fails with an actionable hint instead.
|
|
120
|
+
|
|
121
|
+
## Where things live
|
|
122
|
+
|
|
123
|
+
- Virtual environment: under `${CLAUDE_PLUGIN_DATA}` when Claude Code sets it, otherwise
|
|
124
|
+
`~/.cache/mgraphctl/venv` (`$XDG_CACHE_HOME/mgraphctl/venv` when that variable is set).
|
|
125
|
+
Persistent across plugin updates; safe to delete, it is rebuilt on the next run.
|
|
126
|
+
- Token cache: the OS keychain (macOS Keychain, Windows Credential Locker, Linux Secret
|
|
127
|
+
Service) when one is available, as a single `mgraphctl` item whose account is the cache path.
|
|
128
|
+
Without one it is `~/.mgraphctl/token_cache.json`, mode `0600`, in a `0700` directory.
|
|
129
|
+
`token_store = auto | keyring | file` in the config file, or `MGRAPHCTL_TOKEN_STORE`, picks;
|
|
130
|
+
`status` shows which store is in use. A cache file left by an earlier version is imported into
|
|
131
|
+
the keychain on the first run and then deleted. When the keychain refuses (locked, denied,
|
|
132
|
+
no session bus), the file takes over for that run with one warning on stderr. On macOS a
|
|
133
|
+
rebuilt Python (a new venv, a `uv` upgrade) asks once for keychain access; answer
|
|
134
|
+
"Always Allow". `token_store = "file"` restores the old behaviour.
|
|
135
|
+
- Config file: `~/.mgraphctl/config.toml`, optional (`--config PATH` or `MGRAPHCTL_CONFIG` to
|
|
136
|
+
point elsewhere). Every `MGRAPHCTL_*` setting can go there as the name without the prefix,
|
|
137
|
+
lower-cased; a flag beats an environment variable, which beats the file. `mgraphctl config init`
|
|
138
|
+
writes a commented template, `mgraphctl config set tz Europe/Warsaw` edits one key, and
|
|
139
|
+
`mgraphctl config show` prints where each value came from.
|
|
140
|
+
|
|
141
|
+
```toml
|
|
142
|
+
tenant_id = "contoso.onmicrosoft.com"
|
|
143
|
+
scopes = "extended"
|
|
144
|
+
tz = "Europe/Warsaw"
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Scopes
|
|
148
|
+
|
|
149
|
+
Two named scope sets cover almost everything:
|
|
150
|
+
|
|
151
|
+
- `default` — the 23 scopes the Node `msgraph` skill has always requested: mail read/send,
|
|
152
|
+
calendars, files, sites, chats, channel messages, meeting transcripts, people, contacts, notes,
|
|
153
|
+
tasks, and group membership. This is what `login` requests unless told otherwise.
|
|
154
|
+
- `extended` — `default` plus write access to mail and mailbox settings, presence, directory
|
|
155
|
+
user lookup, shared calendars, chat creation, and team/channel listing. Request it with
|
|
156
|
+
`login --scopes extended`; running it again on an already-consented account adds the new scopes
|
|
157
|
+
with one more consent prompt, no re-login needed.
|
|
158
|
+
|
|
159
|
+
A handful of scopes are on-demand only, requested with `--scope <name>` on the command that needs
|
|
160
|
+
them (`meetings recordings`, `org manager|reports|chain` for another user, `presence get USER`) —
|
|
161
|
+
they are never in `default` or `extended`.
|
|
162
|
+
|
|
163
|
+
Some tenants require an administrator to grant consent once for the app registration. If a
|
|
164
|
+
command fails with `error[CONSENT_REQUIRED]`, the error prints the admin-consent URL to send to
|
|
165
|
+
your Microsoft 365 administrator:
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
https://login.microsoftonline.com/<tenant>/adminconsent?client_id=<client id>
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Differences from `msgraph`
|
|
172
|
+
|
|
173
|
+
What differs from the Node `msgraph` skill:
|
|
174
|
+
|
|
175
|
+
- Commands are noun-verb (`mail list`, `calendar create`, `chats send`) instead of Node's
|
|
176
|
+
flag-driven modes (`--emails`, `--calendar --create`, `--send`).
|
|
177
|
+
- `mail list` defaults to the Inbox; `msgraph`'s `emails` mode listed the whole mailbox. Pass
|
|
178
|
+
`--folder all` for the old behaviour.
|
|
179
|
+
- Exit codes differ: this CLI uses 0 (success), 1 (runtime error), 2 (usage error), 3 (auth or
|
|
180
|
+
permission problem), 4 (not found) — `msgraph` was less consistent, including exiting 0 when
|
|
181
|
+
not logged in.
|
|
182
|
+
|
|
183
|
+
## Troubleshooting
|
|
184
|
+
|
|
185
|
+
- **`mgraphctl: 'uv' is not installed`** — install `uv` (see **Prerequisite** above) and try
|
|
186
|
+
again.
|
|
187
|
+
- **First run is slow** — the shim is building the virtual environment (10-40 seconds); it prints
|
|
188
|
+
a notice to stderr while doing so, and every later run is fast.
|
|
189
|
+
- **`error[CONSENT_REQUIRED]`** — an administrator must grant consent once; the error prints the
|
|
190
|
+
admin-consent URL (see **Scopes** above).
|
|
191
|
+
- **`error[NOT_LOGGED_IN]`** — run `login` yourself in your own terminal; data commands never open
|
|
192
|
+
a browser on their own.
|
|
193
|
+
- **`error[MISSING_SCOPE]`** — the cached sign-in doesn't cover this command; run
|
|
194
|
+
`login --scopes extended`, or `login --scope <name>` for an on-demand scope.
|
|
195
|
+
- **`claims` exits 3 on a stale or missing cache** — it decodes the cached token locally and never
|
|
196
|
+
makes a network call, so it cannot refresh an expired token; run `login` (or `status`, which
|
|
197
|
+
does refresh silently) first.
|
|
198
|
+
- **Device-code login is blocked** — some tenants apply Conditional Access policies that require
|
|
199
|
+
a compliant, registered device; the device-code flow cannot satisfy those and interactive
|
|
200
|
+
browser login is the only option.
|
|
201
|
+
- **Behind a proxy** — set `HTTPS_PROXY` (and `HTTP_PROXY` if needed); the CLI honours the
|
|
202
|
+
standard proxy environment variables.
|
|
203
|
+
- **Times look wrong** — the CLI detects your local IANA time zone automatically; override it with
|
|
204
|
+
`MGRAPHCTL_TZ=Europe/Warsaw` or the global `--tz` option if detection picks the wrong zone.
|
|
205
|
+
|
|
206
|
+
## Development
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
uv sync
|
|
210
|
+
uv run pytest
|
|
211
|
+
uv run ruff check src tests
|
|
212
|
+
uv run ruff format src tests
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
The test suite runs entirely offline against recorded fixtures — no Microsoft 365 account or
|
|
216
|
+
network access is needed to run it.
|
|
217
|
+
|
|
218
|
+
CI (`.github/workflows/ci.yml`) runs the suite on Python 3.11–3.13, ruff, `uv lock --check` and a
|
|
219
|
+
credential scan on every push. Releases are annotated `mgraphctl--vX.Y.Z` tags on `main`, cut with
|
|
220
|
+
`claude plugin tag`; the tag publishes the package to PyPI and a GitHub Release. See `VERSIONING.md`.
|
|
221
|
+
|
|
222
|
+
## Provenance and licence
|
|
223
|
+
|
|
224
|
+
`mgraphctl` is original to this repository: an independent Python re-implementation of the
|
|
225
|
+
`msgraph` skill's feature set, not a port and not tracking any upstream project. Licensed under
|
|
226
|
+
the MIT licence; see `LICENSE`.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
mgraphctl/__init__.py,sha256=6m4fXqsOPfgq6qHZSP3uZcUMtQx1S_4vTHcWDjgcQfg,85
|
|
2
|
+
mgraphctl/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
|
|
3
|
+
mgraphctl/auth.py,sha256=hzFpz6tf9hlZKqxs6Z0A4Ht0_Ey1xLVxiQh9c7Jpq-o,14543
|
|
4
|
+
mgraphctl/cli.py,sha256=EhtVWe_1zyCtMqMgl_I4EMFDLZoKHWcvFfdKrAvEnX0,9328
|
|
5
|
+
mgraphctl/commands/__init__.py,sha256=3sFV44X2QR2q7ivPGtdZxhfnQ4ZS82iWQLgZpGJwsCE,2754
|
|
6
|
+
mgraphctl/commands/api.py,sha256=PPFFuADQ3-_13zRK7LfqmiOlbcBFdFj7HPIPPsaP7ro,5669
|
|
7
|
+
mgraphctl/commands/calendar.py,sha256=gjGKVMgeWdIU1T64vzqCgO_bNfE_Z9Yfev2G0iIV2CY,19344
|
|
8
|
+
mgraphctl/commands/chats.py,sha256=6SbNLan1pOrobdIUJIfdxsqCkehm3mHTqKT33ak6gjE,11105
|
|
9
|
+
mgraphctl/commands/config_cmd.py,sha256=IBd3IXAd6NCfiNJeMECnm0nb3HzYHXCJh6Raj4IWCj4,4893
|
|
10
|
+
mgraphctl/commands/groups.py,sha256=64asbKywPCXk3YcKSn1Dsq0lMNGQTS7nDxKbww88AI8,2021
|
|
11
|
+
mgraphctl/commands/mail.py,sha256=s5L4NfBQSYOKIKZNiaG-eBsQ23sdfkupjmig-IqCMbM,23776
|
|
12
|
+
mgraphctl/commands/mailbox.py,sha256=Pq94Nt8nNsQRM30HNQHI2vIY8HpRB5vkIuVU-vCLb8M,6019
|
|
13
|
+
mgraphctl/commands/meetings.py,sha256=uj_hjMevdEcCJke12u7pWmTINUZ5gV4AX-fh7Qup2O8,10454
|
|
14
|
+
mgraphctl/commands/onedrive.py,sha256=pxGSzRIaVtDYNByEDjMh-VOpRmb2OZdvAVVGwOwoTBk,11218
|
|
15
|
+
mgraphctl/commands/onenote.py,sha256=Bi-uYI390rfFykA0xPzijt7f1g-eJgHn3OB-sNX4hTc,6092
|
|
16
|
+
mgraphctl/commands/org.py,sha256=h3ds4DlMyPMZ071Hjbak0mIcUD76-uKOHJL_sTcr61E,3045
|
|
17
|
+
mgraphctl/commands/people.py,sha256=uGJH0G9PhY5wWDpDxCKWznJOHDAxFknppEJtxeT1y-U,6174
|
|
18
|
+
mgraphctl/commands/planner.py,sha256=QJrxVlV38tOsnylwZkbwmUQHSKdRLiwTyoByAeKWaJI,9317
|
|
19
|
+
mgraphctl/commands/presence.py,sha256=aNf5bJepkm_55VLUVlE3fJEBPpvI2ponGj1EwZPwuQI,3267
|
|
20
|
+
mgraphctl/commands/search.py,sha256=xdAEwbnFYa_E0ZoHhK5Q4iHIJMbM4bIR_CxazkqWnE8,2547
|
|
21
|
+
mgraphctl/commands/sharepoint.py,sha256=AGr66Koo5ZQsa5FRkZmrZbN8pz8i47GIz75JjNDJ2fc,10412
|
|
22
|
+
mgraphctl/commands/teams.py,sha256=HXDEKZlZA438yrl2gYYiMqto5yJ9f3v2QXDmXpzWZQA,7733
|
|
23
|
+
mgraphctl/commands/todo.py,sha256=GpFpkwW9duHOsHV4HxgDisXyYTFfRfSqH1UnGJ7_iac,8028
|
|
24
|
+
mgraphctl/commands/top.py,sha256=5dM1dhZSkAO5FRTcz7j3m7F-wMSpYyj270UrRzhXGcc,10074
|
|
25
|
+
mgraphctl/config.py,sha256=svlabXONVULKy1lk5DR36IrMOVg18_DduY7RwNr7-V8,13619
|
|
26
|
+
mgraphctl/errors.py,sha256=R6tZaStKz9hA2DZ2iqGH0J4p-FXIHlOpNTtvb-ghZ44,8403
|
|
27
|
+
mgraphctl/fixtures.py,sha256=9-9Tea99C9Nb72i_yM9190mlaLV1XYOrP53lRIuC1ng,5820
|
|
28
|
+
mgraphctl/graph/__init__.py,sha256=BDpGqimUu-01ZofZaKoCH9GS11ZfZqIxTjeTrQOh1zE,69
|
|
29
|
+
mgraphctl/graph/calendar.py,sha256=BR60VruG0SDiy_B2tkoub4quXgEAKbNFCpXJV6dBMYs,11040
|
|
30
|
+
mgraphctl/graph/chats.py,sha256=wroxAY-VuCNMxlA-Ux0BkrlDkrUfTtg21sWUKCQPAMg,16641
|
|
31
|
+
mgraphctl/graph/files.py,sha256=B0UYeAyrqc-uz-jZXD7p-iM_oIdkvrVnrqeG-5pCyF4,8966
|
|
32
|
+
mgraphctl/graph/groups.py,sha256=X_-eHxWnw5HaKYq4DUyzf8PClB-c1rxKhcffpBpFoI0,1828
|
|
33
|
+
mgraphctl/graph/mail.py,sha256=ROGZ83lwh85hcFX3yMDxiuBgVdnky6qTzh-o-QJbDxk,22489
|
|
34
|
+
mgraphctl/graph/mailbox.py,sha256=9YN83MamfBpZyEMlDAqPgbKqwSw80RNPHCnwRHw0e8c,2806
|
|
35
|
+
mgraphctl/graph/meetings.py,sha256=eua6UOpjuogiB8DvqBxp2uKsmyV--w_4UCrVR-4a3TU,10531
|
|
36
|
+
mgraphctl/graph/onedrive.py,sha256=fjhZzo-x_2g0u7panqxJLUOacmx9OdcUHOp1KFZoMiI,1307
|
|
37
|
+
mgraphctl/graph/onenote.py,sha256=2zcNJN_-mcqkrsbFFsBbp5LTAmo1nwcg1MMQ3K3uwIw,4862
|
|
38
|
+
mgraphctl/graph/org.py,sha256=zBPl6yF9sa2ecNYvvxjyJtRTEWt64CgszIfVZgxdXEc,3267
|
|
39
|
+
mgraphctl/graph/people.py,sha256=on21R8FfyK_QpMKOMEIahUhrHqVz35-e9rq6yYWT2DA,3108
|
|
40
|
+
mgraphctl/graph/planner.py,sha256=GoyR1FfYvDEFRYb5oFJMwCRA_MLj12KYPqZZX6kV4Ag,15608
|
|
41
|
+
mgraphctl/graph/presence.py,sha256=6bjVv4riJS22Jd3fvhaUw9ig7PObAklIjo8-JydjNmI,2756
|
|
42
|
+
mgraphctl/graph/search.py,sha256=eHecYI277FBsMlIZ2lnPRm_3EVc8uWEBtvlsy_ecc70,6764
|
|
43
|
+
mgraphctl/graph/sharepoint.py,sha256=xjd3wugHFJk34EERWHx_Ax9_Lexe6j5S85s9J8d7aHM,11550
|
|
44
|
+
mgraphctl/graph/teams.py,sha256=DLpyw8TyHEHH00hIQjdYGE31WdZ46cvZSeKfYjjZRYU,8179
|
|
45
|
+
mgraphctl/graph/todo.py,sha256=UrGMAYqoRqfwKXkqplr18YLyOgNQmMuFaa1iW-1Xqis,7135
|
|
46
|
+
mgraphctl/graph/users.py,sha256=Yi58IEz6ZxkaukjZ6DI8HcUuA55bIVz9emA0hK4bySE,2883
|
|
47
|
+
mgraphctl/html.py,sha256=uPMMaqT4SNOm1mDYo8jt6wlU5uZ5zZOgCdVMbHcGrU4,5163
|
|
48
|
+
mgraphctl/http.py,sha256=IT3vzeDPnsZB8Wv3RWkFMfvkEMbFZQErwsZq6q-Hm34,32883
|
|
49
|
+
mgraphctl/odata.py,sha256=GcJIfvls06KSmzvwFDyWmAhu8dX6SPvOrDP3GqSGWrI,2707
|
|
50
|
+
mgraphctl/render.py,sha256=5epqUQs6g_oTGCJ6Nmqe5FDeuxPveYtlAcjuXVN_pfM,23543
|
|
51
|
+
mgraphctl/resolve.py,sha256=hbrJVLZfPAciZGysUUoY9WFL6VXNO3UtXlIizKob-f0,3759
|
|
52
|
+
mgraphctl/token_store.py,sha256=QGlWC3rd2epDmnpNa3y8DbVfwo6Fe99LZHY3nDo4MaU,6402
|
|
53
|
+
mgraphctl-0.1.0.dist-info/licenses/LICENSE,sha256=spQ0BfGkOfbfuyGqXAunPi6dQTgczXrQTHH1OlcSTJo,1076
|
|
54
|
+
mgraphctl-0.1.0.dist-info/WHEEL,sha256=y6e-a5KI2W-qDAJKfh9Xr81bin8NgUdfWgz3VSXXpe4,80
|
|
55
|
+
mgraphctl-0.1.0.dist-info/entry_points.txt,sha256=w0NU2pDL_oFRl630DYjJPWSRUvJelLzmF3ssZGjSkH4,50
|
|
56
|
+
mgraphctl-0.1.0.dist-info/METADATA,sha256=9bW5VyVuJEGahZx1nHgNyxC7Vx-PHfYaNTfJ_oP2bLI,10554
|
|
57
|
+
mgraphctl-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sviatoslav Sviridov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|