@codemieai/code 0.0.47 → 0.0.49
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/dist/agents/plugins/claude/plugin/.claude-plugin/plugin.json +1 -1
- package/dist/agents/plugins/claude/plugin/skills/msgraph/README.md +35 -36
- package/dist/agents/plugins/claude/plugin/skills/msgraph/SKILL.md +77 -47
- package/dist/agents/plugins/claude/plugin/skills/msgraph/scripts/msgraph.js +890 -0
- package/dist/agents/plugins/claude/plugin/skills/report-issue/SKILL.md +288 -0
- package/package.json +1 -1
- package/dist/agents/plugins/claude/plugin/skills/msgraph/scripts/msgraph.py +0 -785
|
@@ -1,785 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
msgraph.py — Microsoft Graph API CLI for Claude Code skill
|
|
4
|
-
|
|
5
|
-
Authentication: MSAL device code flow with persistent token cache.
|
|
6
|
-
First time: python msgraph.py login
|
|
7
|
-
Subsequent: token refreshed silently from cache.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
import argparse
|
|
11
|
-
import json
|
|
12
|
-
import sys
|
|
13
|
-
from datetime import datetime, timezone
|
|
14
|
-
from pathlib import Path
|
|
15
|
-
|
|
16
|
-
try:
|
|
17
|
-
from msal import PublicClientApplication, SerializableTokenCache
|
|
18
|
-
import requests
|
|
19
|
-
except ImportError:
|
|
20
|
-
print("Missing dependencies. Install with:")
|
|
21
|
-
print(" pip install msal requests")
|
|
22
|
-
sys.exit(1)
|
|
23
|
-
|
|
24
|
-
# ── Config ────────────────────────────────────────────────────────────────────
|
|
25
|
-
CLIENT_ID = "14d82eec-204b-4c2f-b7e8-296a70dab67e" # MS GraphAPI public client
|
|
26
|
-
AUTHORITY = "https://login.microsoftonline.com/common"
|
|
27
|
-
SCOPES = [
|
|
28
|
-
"User.Read",
|
|
29
|
-
"Mail.Read",
|
|
30
|
-
"Mail.Send",
|
|
31
|
-
"Calendars.Read",
|
|
32
|
-
"Calendars.ReadWrite",
|
|
33
|
-
"Files.Read",
|
|
34
|
-
"Files.ReadWrite",
|
|
35
|
-
"Sites.Read.All",
|
|
36
|
-
"Chat.Read",
|
|
37
|
-
"Chat.ReadWrite",
|
|
38
|
-
"People.Read",
|
|
39
|
-
"Contacts.Read",
|
|
40
|
-
]
|
|
41
|
-
CACHE_FILE = Path.home() / ".ms_graph_token_cache.json"
|
|
42
|
-
GRAPH_BASE = "https://graph.microsoft.com/v1.0"
|
|
43
|
-
|
|
44
|
-
# ── Token Cache ───────────────────────────────────────────────────────────────
|
|
45
|
-
def load_cache() -> SerializableTokenCache:
|
|
46
|
-
cache = SerializableTokenCache()
|
|
47
|
-
if CACHE_FILE.exists():
|
|
48
|
-
cache.deserialize(CACHE_FILE.read_text())
|
|
49
|
-
return cache
|
|
50
|
-
|
|
51
|
-
def save_cache(cache: SerializableTokenCache):
|
|
52
|
-
if cache.has_state_changed:
|
|
53
|
-
CACHE_FILE.write_text(cache.serialize())
|
|
54
|
-
|
|
55
|
-
# ── Authentication ────────────────────────────────────────────────────────────
|
|
56
|
-
def get_access_token(force_login: bool = False) -> str:
|
|
57
|
-
cache = load_cache()
|
|
58
|
-
app = PublicClientApplication(
|
|
59
|
-
client_id=CLIENT_ID,
|
|
60
|
-
authority=AUTHORITY,
|
|
61
|
-
token_cache=cache
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
if not force_login:
|
|
65
|
-
accounts = app.get_accounts()
|
|
66
|
-
if accounts:
|
|
67
|
-
result = app.acquire_token_silent(SCOPES, account=accounts[0])
|
|
68
|
-
if result and "access_token" in result:
|
|
69
|
-
save_cache(cache)
|
|
70
|
-
return result["access_token"]
|
|
71
|
-
|
|
72
|
-
# Device Code Flow
|
|
73
|
-
flow = app.initiate_device_flow(scopes=SCOPES)
|
|
74
|
-
if "user_code" not in flow:
|
|
75
|
-
raise RuntimeError(f"Failed to initiate device flow: {flow.get('error_description')}")
|
|
76
|
-
|
|
77
|
-
print("\n" + "=" * 60)
|
|
78
|
-
print(flow["message"])
|
|
79
|
-
print("=" * 60 + "\n")
|
|
80
|
-
|
|
81
|
-
result = app.acquire_token_by_device_flow(flow)
|
|
82
|
-
if "access_token" not in result:
|
|
83
|
-
raise RuntimeError(f"Authentication failed: {result.get('error_description')}")
|
|
84
|
-
|
|
85
|
-
save_cache(cache)
|
|
86
|
-
return result["access_token"]
|
|
87
|
-
|
|
88
|
-
def get_token_or_exit() -> str:
|
|
89
|
-
"""Get token from cache only — exit with helpful message if not logged in or token expired."""
|
|
90
|
-
cache = load_cache()
|
|
91
|
-
app = PublicClientApplication(
|
|
92
|
-
client_id=CLIENT_ID,
|
|
93
|
-
authority=AUTHORITY,
|
|
94
|
-
token_cache=cache
|
|
95
|
-
)
|
|
96
|
-
accounts = app.get_accounts()
|
|
97
|
-
if not accounts:
|
|
98
|
-
print("NOT_LOGGED_IN")
|
|
99
|
-
sys.exit(2)
|
|
100
|
-
result = app.acquire_token_silent(SCOPES, account=accounts[0])
|
|
101
|
-
if not result or "access_token" not in result:
|
|
102
|
-
print("TOKEN_EXPIRED")
|
|
103
|
-
sys.exit(2)
|
|
104
|
-
save_cache(cache)
|
|
105
|
-
return result["access_token"]
|
|
106
|
-
|
|
107
|
-
# ── Graph API Helpers ─────────────────────────────────────────────────────────
|
|
108
|
-
def graph_get(endpoint: str, token: str, params: dict | None = None) -> dict:
|
|
109
|
-
r = requests.get(
|
|
110
|
-
f"{GRAPH_BASE}{endpoint}",
|
|
111
|
-
headers={"Authorization": f"Bearer {token}"},
|
|
112
|
-
params=params or {}
|
|
113
|
-
)
|
|
114
|
-
r.raise_for_status()
|
|
115
|
-
return r.json()
|
|
116
|
-
|
|
117
|
-
def graph_post(endpoint: str, token: str, body: dict) -> dict:
|
|
118
|
-
r = requests.post(
|
|
119
|
-
f"{GRAPH_BASE}{endpoint}",
|
|
120
|
-
headers={
|
|
121
|
-
"Authorization": f"Bearer {token}",
|
|
122
|
-
"Content-Type": "application/json"
|
|
123
|
-
},
|
|
124
|
-
json=body
|
|
125
|
-
)
|
|
126
|
-
r.raise_for_status()
|
|
127
|
-
return r.json() if r.content else {}
|
|
128
|
-
|
|
129
|
-
def graph_download(endpoint: str, token: str) -> bytes:
|
|
130
|
-
r = requests.get(
|
|
131
|
-
f"{GRAPH_BASE}{endpoint}",
|
|
132
|
-
headers={"Authorization": f"Bearer {token}"},
|
|
133
|
-
allow_redirects=True
|
|
134
|
-
)
|
|
135
|
-
r.raise_for_status()
|
|
136
|
-
return r.content
|
|
137
|
-
|
|
138
|
-
def graph_upload(endpoint: str, token: str, content: bytes, content_type: str = "application/octet-stream") -> dict:
|
|
139
|
-
r = requests.put(
|
|
140
|
-
f"{GRAPH_BASE}{endpoint}",
|
|
141
|
-
headers={
|
|
142
|
-
"Authorization": f"Bearer {token}",
|
|
143
|
-
"Content-Type": content_type
|
|
144
|
-
},
|
|
145
|
-
data=content
|
|
146
|
-
)
|
|
147
|
-
r.raise_for_status()
|
|
148
|
-
return r.json()
|
|
149
|
-
|
|
150
|
-
# ── Formatters ────────────────────────────────────────────────────────────────
|
|
151
|
-
def fmt_dt(iso: str) -> str:
|
|
152
|
-
"""Format ISO datetime to readable string."""
|
|
153
|
-
try:
|
|
154
|
-
dt = datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
|
155
|
-
return dt.strftime("%Y-%m-%d %H:%M")
|
|
156
|
-
except Exception:
|
|
157
|
-
return iso[:16].replace("T", " ")
|
|
158
|
-
|
|
159
|
-
def fmt_size(size: int) -> str:
|
|
160
|
-
for unit in ["B", "KB", "MB", "GB"]:
|
|
161
|
-
if size < 1024:
|
|
162
|
-
return f"{size:.1f} {unit}"
|
|
163
|
-
size /= 1024
|
|
164
|
-
return f"{size:.1f} TB"
|
|
165
|
-
|
|
166
|
-
def print_json(data: dict | list):
|
|
167
|
-
print(json.dumps(data, indent=2, ensure_ascii=False))
|
|
168
|
-
|
|
169
|
-
# ── Commands ──────────────────────────────────────────────────────────────────
|
|
170
|
-
|
|
171
|
-
def cmd_login(args):
|
|
172
|
-
"""Authenticate and cache credentials."""
|
|
173
|
-
print("Starting Microsoft authentication...")
|
|
174
|
-
token = get_access_token(force_login=True)
|
|
175
|
-
me = graph_get("/me", token)
|
|
176
|
-
print(f"\nLogged in as: {me['displayName']} <{me['userPrincipalName']}>")
|
|
177
|
-
print(f"User ID: {me['id']}")
|
|
178
|
-
print(f"Token cached at: {CACHE_FILE}")
|
|
179
|
-
|
|
180
|
-
def cmd_logout(args):
|
|
181
|
-
"""Remove cached credentials."""
|
|
182
|
-
if CACHE_FILE.exists():
|
|
183
|
-
CACHE_FILE.unlink()
|
|
184
|
-
print(f"Logged out. Cache removed: {CACHE_FILE}")
|
|
185
|
-
else:
|
|
186
|
-
print("No cached credentials found.")
|
|
187
|
-
|
|
188
|
-
def cmd_status(args):
|
|
189
|
-
"""Check login status."""
|
|
190
|
-
cache = load_cache()
|
|
191
|
-
app = PublicClientApplication(client_id=CLIENT_ID, authority=AUTHORITY, token_cache=cache)
|
|
192
|
-
accounts = app.get_accounts()
|
|
193
|
-
if not accounts:
|
|
194
|
-
print("NOT_LOGGED_IN")
|
|
195
|
-
print(f"\nTo login, run:\n python {Path(__file__).name} login")
|
|
196
|
-
return
|
|
197
|
-
# Verify token can actually be acquired (not just that account exists in cache)
|
|
198
|
-
result = app.acquire_token_silent(SCOPES, account=accounts[0])
|
|
199
|
-
if result and "access_token" in result:
|
|
200
|
-
save_cache(cache)
|
|
201
|
-
print(f"Logged in as: {accounts[0]['username']}")
|
|
202
|
-
print(f"Cache file: {CACHE_FILE}")
|
|
203
|
-
else:
|
|
204
|
-
print("TOKEN_EXPIRED")
|
|
205
|
-
print(f"Account: {accounts[0]['username']}")
|
|
206
|
-
print(f"\nSession expired. Re-authenticate with:\n python {Path(__file__).name} login")
|
|
207
|
-
|
|
208
|
-
def cmd_me(args):
|
|
209
|
-
"""Show user profile information."""
|
|
210
|
-
token = get_token_or_exit()
|
|
211
|
-
me = graph_get("/me", token)
|
|
212
|
-
fields = ["displayName", "userPrincipalName", "id", "mail", "jobTitle",
|
|
213
|
-
"department", "officeLocation", "businessPhones", "mobilePhone"]
|
|
214
|
-
if args.json:
|
|
215
|
-
print_json({k: me.get(k) for k in fields if me.get(k)})
|
|
216
|
-
return
|
|
217
|
-
print(f"Name : {me.get('displayName', 'N/A')}")
|
|
218
|
-
print(f"Email : {me.get('userPrincipalName', 'N/A')}")
|
|
219
|
-
print(f"Job Title : {me.get('jobTitle', 'N/A')}")
|
|
220
|
-
print(f"Department : {me.get('department', 'N/A')}")
|
|
221
|
-
print(f"Office : {me.get('officeLocation', 'N/A')}")
|
|
222
|
-
print(f"Phone : {me.get('businessPhones', ['N/A'])[0] if me.get('businessPhones') else 'N/A'}")
|
|
223
|
-
print(f"User ID : {me.get('id', 'N/A')}")
|
|
224
|
-
|
|
225
|
-
def cmd_emails(args):
|
|
226
|
-
"""List, read, send or search emails."""
|
|
227
|
-
token = get_token_or_exit()
|
|
228
|
-
|
|
229
|
-
if args.send:
|
|
230
|
-
# Send email: --send "To <email>" --subject "Subject" --body "Body"
|
|
231
|
-
to_email = args.send
|
|
232
|
-
body_content = args.body or ""
|
|
233
|
-
subject = args.subject or "(no subject)"
|
|
234
|
-
payload = {
|
|
235
|
-
"message": {
|
|
236
|
-
"subject": subject,
|
|
237
|
-
"body": {"contentType": "Text", "content": body_content},
|
|
238
|
-
"toRecipients": [{"emailAddress": {"address": to_email}}]
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
graph_post("/me/sendMail", token, payload)
|
|
242
|
-
print(f"Email sent to {to_email}")
|
|
243
|
-
return
|
|
244
|
-
|
|
245
|
-
if args.read:
|
|
246
|
-
# Read a specific email by ID
|
|
247
|
-
msg = graph_get(f"/me/messages/{args.read}", token)
|
|
248
|
-
print(f"Subject : {msg.get('subject')}")
|
|
249
|
-
print(f"From : {msg['from']['emailAddress']['name']} <{msg['from']['emailAddress']['address']}>")
|
|
250
|
-
print(f"Date : {fmt_dt(msg['receivedDateTime'])}")
|
|
251
|
-
print(f"Read : {'Yes' if msg['isRead'] else 'No'}")
|
|
252
|
-
print(f"\n{'─'*60}")
|
|
253
|
-
body = msg.get("body", {})
|
|
254
|
-
if body.get("contentType") == "text":
|
|
255
|
-
print(body.get("content", ""))
|
|
256
|
-
else:
|
|
257
|
-
# Strip basic HTML tags for readability
|
|
258
|
-
import re
|
|
259
|
-
text = re.sub(r'<[^>]+>', '', body.get("content", ""))
|
|
260
|
-
print(text[:2000])
|
|
261
|
-
return
|
|
262
|
-
|
|
263
|
-
# List/search emails
|
|
264
|
-
params: dict = {
|
|
265
|
-
"$top": args.limit,
|
|
266
|
-
"$select": "id,subject,from,receivedDateTime,isRead,hasAttachments,importance",
|
|
267
|
-
"$orderby": "receivedDateTime desc"
|
|
268
|
-
}
|
|
269
|
-
if args.search:
|
|
270
|
-
params["$search"] = f'"{args.search}"'
|
|
271
|
-
params.pop("$orderby", None) # $search incompatible with $orderby
|
|
272
|
-
if args.folder:
|
|
273
|
-
endpoint = f"/me/mailFolders/{args.folder}/messages"
|
|
274
|
-
elif args.unread:
|
|
275
|
-
params["$filter"] = "isRead eq false"
|
|
276
|
-
endpoint = "/me/messages"
|
|
277
|
-
else:
|
|
278
|
-
endpoint = "/me/messages"
|
|
279
|
-
|
|
280
|
-
data = graph_get(endpoint, token, params)
|
|
281
|
-
emails = data.get("value", [])
|
|
282
|
-
|
|
283
|
-
if args.json:
|
|
284
|
-
print_json(emails)
|
|
285
|
-
return
|
|
286
|
-
|
|
287
|
-
if not emails:
|
|
288
|
-
print("No emails found.")
|
|
289
|
-
return
|
|
290
|
-
|
|
291
|
-
print(f"\n{'ID':<36} {'Date':<16} {'Rd'} {'Subject'}")
|
|
292
|
-
print("─" * 80)
|
|
293
|
-
for e in emails:
|
|
294
|
-
read_mark = "✓" if e["isRead"] else "●"
|
|
295
|
-
attach = "📎" if e.get("hasAttachments") else " "
|
|
296
|
-
subject = e.get("subject", "(no subject)")[:45]
|
|
297
|
-
sender = e["from"]["emailAddress"].get("name", "")[:20]
|
|
298
|
-
date = fmt_dt(e["receivedDateTime"])
|
|
299
|
-
print(f"{e['id'][:36]} {date} {read_mark} {attach} {subject} ({sender})")
|
|
300
|
-
|
|
301
|
-
def cmd_calendar(args):
|
|
302
|
-
"""List or create calendar events."""
|
|
303
|
-
token = get_token_or_exit()
|
|
304
|
-
|
|
305
|
-
if args.create:
|
|
306
|
-
# Create event: --create "Title" --start "2024-03-15T10:00" --end "2024-03-15T11:00"
|
|
307
|
-
if not args.start or not args.end:
|
|
308
|
-
print("Error: --create requires --start and --end (format: YYYY-MM-DDTHH:MM)")
|
|
309
|
-
sys.exit(1)
|
|
310
|
-
tz = args.timezone or "UTC"
|
|
311
|
-
payload = {
|
|
312
|
-
"subject": args.create,
|
|
313
|
-
"start": {"dateTime": args.start, "timeZone": tz},
|
|
314
|
-
"end": {"dateTime": args.end, "timeZone": tz},
|
|
315
|
-
}
|
|
316
|
-
if args.location:
|
|
317
|
-
payload["location"] = {"displayName": args.location}
|
|
318
|
-
if args.body:
|
|
319
|
-
payload["body"] = {"contentType": "Text", "content": args.body}
|
|
320
|
-
event = graph_post("/me/events", token, payload)
|
|
321
|
-
print(f"Event created: {event.get('subject')}")
|
|
322
|
-
print(f"ID: {event.get('id')}")
|
|
323
|
-
return
|
|
324
|
-
|
|
325
|
-
if args.availability:
|
|
326
|
-
# Check free/busy for a time range
|
|
327
|
-
start = args.start or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
|
328
|
-
end = args.end or datetime.now(timezone.utc).replace(hour=23, minute=59).strftime("%Y-%m-%dT%H:%M:%S")
|
|
329
|
-
view = graph_get(
|
|
330
|
-
"/me/calendarView",
|
|
331
|
-
token,
|
|
332
|
-
{"startDateTime": start, "endDateTime": end,
|
|
333
|
-
"$select": "subject,start,end,showAs", "$orderby": "start/dateTime"}
|
|
334
|
-
)
|
|
335
|
-
events = view.get("value", [])
|
|
336
|
-
if not events:
|
|
337
|
-
print(f"You're free between {start[:16]} and {end[:16]}.")
|
|
338
|
-
else:
|
|
339
|
-
print(f"\nBusy slots ({len(events)} events):")
|
|
340
|
-
for e in events:
|
|
341
|
-
print(f" {fmt_dt(e['start']['dateTime'])} — {fmt_dt(e['end']['dateTime'])}: {e.get('subject', '(no title)')}")
|
|
342
|
-
return
|
|
343
|
-
|
|
344
|
-
# List upcoming events
|
|
345
|
-
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
346
|
-
data = graph_get(
|
|
347
|
-
"/me/calendarView",
|
|
348
|
-
token,
|
|
349
|
-
{
|
|
350
|
-
"startDateTime": now,
|
|
351
|
-
"endDateTime": f"{datetime.now(timezone.utc).year + 1}-01-01T00:00:00Z",
|
|
352
|
-
"$top": args.limit,
|
|
353
|
-
"$select": "id,subject,start,end,location,organizer,isOnlineMeeting,onlineMeetingUrl",
|
|
354
|
-
"$orderby": "start/dateTime"
|
|
355
|
-
}
|
|
356
|
-
)
|
|
357
|
-
events = data.get("value", [])
|
|
358
|
-
|
|
359
|
-
if args.json:
|
|
360
|
-
print_json(events)
|
|
361
|
-
return
|
|
362
|
-
|
|
363
|
-
if not events:
|
|
364
|
-
print("No upcoming events.")
|
|
365
|
-
return
|
|
366
|
-
|
|
367
|
-
print(f"\n{'Date & Time':<20} {'Duration':<10} {'Title'}")
|
|
368
|
-
print("─" * 80)
|
|
369
|
-
for e in events:
|
|
370
|
-
start = fmt_dt(e["start"]["dateTime"])
|
|
371
|
-
title = e.get("subject", "(no title)")[:45]
|
|
372
|
-
organizer = e["organizer"]["emailAddress"].get("name", "")[:20]
|
|
373
|
-
online = "🎥" if e.get("isOnlineMeeting") else " "
|
|
374
|
-
loc = e.get("location", {}).get("displayName", "")[:20]
|
|
375
|
-
print(f"{start:<20} {online} {title} ({organizer})" + (f" @ {loc}" if loc else ""))
|
|
376
|
-
|
|
377
|
-
def cmd_sharepoint(args):
|
|
378
|
-
"""Browse SharePoint sites and files."""
|
|
379
|
-
token = get_token_or_exit()
|
|
380
|
-
|
|
381
|
-
if args.sites:
|
|
382
|
-
# List all joined sites
|
|
383
|
-
data = graph_get("/me/followedSites", token, {"$select": "id,displayName,webUrl"})
|
|
384
|
-
sites = data.get("value", [])
|
|
385
|
-
if not sites:
|
|
386
|
-
# Fallback: search for sites
|
|
387
|
-
data = graph_get("/sites", token, {"search": "*", "$top": args.limit})
|
|
388
|
-
sites = data.get("value", [])
|
|
389
|
-
if args.json:
|
|
390
|
-
print_json(sites)
|
|
391
|
-
return
|
|
392
|
-
print(f"\n{'ID':<50} {'Name'}")
|
|
393
|
-
print("─" * 80)
|
|
394
|
-
for s in sites[:args.limit]:
|
|
395
|
-
print(f"{s['id']:<50} {s.get('displayName', 'N/A')}")
|
|
396
|
-
print(f" URL: {s.get('webUrl', 'N/A')}")
|
|
397
|
-
return
|
|
398
|
-
|
|
399
|
-
if args.site:
|
|
400
|
-
# Browse files in a site's drive
|
|
401
|
-
site_id = args.site
|
|
402
|
-
path = args.path or "root"
|
|
403
|
-
endpoint = f"/sites/{site_id}/drive/root/children" if path == "root" else f"/sites/{site_id}/drive/root:/{path}:/children"
|
|
404
|
-
data = graph_get(endpoint, token, {"$top": args.limit, "$select": "id,name,size,lastModifiedDateTime,file,folder"})
|
|
405
|
-
items = data.get("value", [])
|
|
406
|
-
if args.json:
|
|
407
|
-
print_json(items)
|
|
408
|
-
return
|
|
409
|
-
print(f"\nFiles in site {site_id} / {path}:")
|
|
410
|
-
print("─" * 60)
|
|
411
|
-
for item in items:
|
|
412
|
-
kind = "📁" if "folder" in item else "📄"
|
|
413
|
-
size = fmt_size(item.get("size", 0)) if "file" in item else ""
|
|
414
|
-
modified = fmt_dt(item.get("lastModifiedDateTime", ""))
|
|
415
|
-
print(f" {kind} {item['name']:<40} {size:<10} {modified}")
|
|
416
|
-
return
|
|
417
|
-
|
|
418
|
-
if args.download:
|
|
419
|
-
# Download a file by item ID from personal drive (use --site for SharePoint)
|
|
420
|
-
content = graph_download(f"/me/drive/items/{args.download}/content", token)
|
|
421
|
-
out_path = Path(args.output or f"downloaded_{args.download[:8]}")
|
|
422
|
-
out_path.write_bytes(content)
|
|
423
|
-
print(f"Downloaded {len(content)} bytes to {out_path}")
|
|
424
|
-
return
|
|
425
|
-
|
|
426
|
-
print("SharePoint commands: --sites | --site SITE_ID [--path PATH] | --download ITEM_ID")
|
|
427
|
-
|
|
428
|
-
def cmd_teams(args):
|
|
429
|
-
"""List Teams chats and messages."""
|
|
430
|
-
token = get_token_or_exit()
|
|
431
|
-
|
|
432
|
-
if args.chats:
|
|
433
|
-
# List all chats
|
|
434
|
-
data = graph_get("/me/chats", token, {
|
|
435
|
-
"$top": args.limit,
|
|
436
|
-
"$select": "id,topic,chatType,lastUpdatedDateTime"
|
|
437
|
-
})
|
|
438
|
-
chats = data.get("value", [])
|
|
439
|
-
if args.json:
|
|
440
|
-
print_json(chats)
|
|
441
|
-
return
|
|
442
|
-
print(f"\n{'Chat ID':<50} {'Type':<10} {'Topic'}")
|
|
443
|
-
print("─" * 80)
|
|
444
|
-
for c in chats:
|
|
445
|
-
topic = c.get("topic") or "(direct message)"
|
|
446
|
-
print(f"{c['id']:<50} {c.get('chatType',''):<10} {topic}")
|
|
447
|
-
return
|
|
448
|
-
|
|
449
|
-
if args.messages:
|
|
450
|
-
# Get messages in a chat
|
|
451
|
-
data = graph_get(f"/me/chats/{args.messages}/messages", token, {
|
|
452
|
-
"$top": args.limit,
|
|
453
|
-
"$select": "id,from,body,createdDateTime"
|
|
454
|
-
})
|
|
455
|
-
msgs = data.get("value", [])
|
|
456
|
-
if args.json:
|
|
457
|
-
print_json(msgs)
|
|
458
|
-
return
|
|
459
|
-
print(f"\nMessages in chat {args.messages[:20]}...:")
|
|
460
|
-
print("─" * 60)
|
|
461
|
-
for m in reversed(msgs):
|
|
462
|
-
sender = m.get("from", {}).get("user", {}).get("displayName", "Unknown") if m.get("from") else "System"
|
|
463
|
-
time = fmt_dt(m.get("createdDateTime", ""))
|
|
464
|
-
import re
|
|
465
|
-
body = re.sub(r'<[^>]+>', '', m.get("body", {}).get("content", ""))[:200]
|
|
466
|
-
print(f"[{time}] {sender}: {body}")
|
|
467
|
-
return
|
|
468
|
-
|
|
469
|
-
if args.send and args.chat_id:
|
|
470
|
-
# Send message to a chat
|
|
471
|
-
payload = {"body": {"content": args.send}}
|
|
472
|
-
result = graph_post(f"/me/chats/{args.chat_id}/messages", token, payload)
|
|
473
|
-
print(f"Message sent. ID: {result.get('id')}")
|
|
474
|
-
return
|
|
475
|
-
|
|
476
|
-
if args.teams_list:
|
|
477
|
-
# List joined teams
|
|
478
|
-
data = graph_get("/me/joinedTeams", token, {"$select": "id,displayName,description"})
|
|
479
|
-
teams = data.get("value", [])
|
|
480
|
-
if args.json:
|
|
481
|
-
print_json(teams)
|
|
482
|
-
return
|
|
483
|
-
for t in teams:
|
|
484
|
-
print(f"{t['id'][:36]} {t['displayName']}")
|
|
485
|
-
return
|
|
486
|
-
|
|
487
|
-
print("Teams commands: --chats | --messages CHAT_ID | --send MSG --chat-id CHAT_ID | --teams-list")
|
|
488
|
-
|
|
489
|
-
def cmd_onedrive(args):
|
|
490
|
-
"""Browse, upload and download OneDrive files."""
|
|
491
|
-
token = get_token_or_exit()
|
|
492
|
-
|
|
493
|
-
if args.upload:
|
|
494
|
-
src = Path(args.upload)
|
|
495
|
-
if not src.exists():
|
|
496
|
-
print(f"File not found: {src}")
|
|
497
|
-
sys.exit(1)
|
|
498
|
-
dest_path = args.dest or src.name
|
|
499
|
-
content = src.read_bytes()
|
|
500
|
-
result = graph_upload(f"/me/drive/root:/{dest_path}:/content", token, content)
|
|
501
|
-
print(f"Uploaded: {result.get('name')} ({fmt_size(result.get('size', 0))})")
|
|
502
|
-
print(f"ID: {result.get('id')}")
|
|
503
|
-
return
|
|
504
|
-
|
|
505
|
-
if args.download:
|
|
506
|
-
content = graph_download(f"/me/drive/items/{args.download}/content", token)
|
|
507
|
-
out_path = Path(args.output or f"download_{args.download[:8]}")
|
|
508
|
-
out_path.write_bytes(content)
|
|
509
|
-
print(f"Downloaded {fmt_size(len(content))} to {out_path}")
|
|
510
|
-
return
|
|
511
|
-
|
|
512
|
-
if args.info:
|
|
513
|
-
item = graph_get(f"/me/drive/items/{args.info}", token)
|
|
514
|
-
if args.json:
|
|
515
|
-
print_json(item)
|
|
516
|
-
return
|
|
517
|
-
print(f"Name : {item.get('name')}")
|
|
518
|
-
print(f"Size : {fmt_size(item.get('size', 0))}")
|
|
519
|
-
print(f"Modified: {fmt_dt(item.get('lastModifiedDateTime', ''))}")
|
|
520
|
-
print(f"URL : {item.get('webUrl', 'N/A')}")
|
|
521
|
-
return
|
|
522
|
-
|
|
523
|
-
# List files in a path
|
|
524
|
-
path = args.path or ""
|
|
525
|
-
endpoint = "/me/drive/root/children" if not path else f"/me/drive/root:/{path}:/children"
|
|
526
|
-
data = graph_get(endpoint, token, {
|
|
527
|
-
"$top": args.limit,
|
|
528
|
-
"$select": "id,name,size,lastModifiedDateTime,file,folder",
|
|
529
|
-
"$orderby": "name"
|
|
530
|
-
})
|
|
531
|
-
items = data.get("value", [])
|
|
532
|
-
|
|
533
|
-
if args.json:
|
|
534
|
-
print_json(items)
|
|
535
|
-
return
|
|
536
|
-
|
|
537
|
-
if not items:
|
|
538
|
-
print(f"No files found in /{path or ''}")
|
|
539
|
-
return
|
|
540
|
-
|
|
541
|
-
print(f"\nOneDrive: /{path or ''}")
|
|
542
|
-
print("─" * 60)
|
|
543
|
-
for item in items:
|
|
544
|
-
kind = "📁" if "folder" in item else "📄"
|
|
545
|
-
size = fmt_size(item.get("size", 0)) if "file" in item else ""
|
|
546
|
-
modified = fmt_dt(item.get("lastModifiedDateTime", ""))
|
|
547
|
-
count = f" ({item['folder']['childCount']} items)" if "folder" in item else ""
|
|
548
|
-
print(f" {kind} {item['id'][:16]} {item['name']:<40} {size:<10} {modified}{count}")
|
|
549
|
-
|
|
550
|
-
def cmd_people(args):
|
|
551
|
-
"""List relevant people and contacts."""
|
|
552
|
-
token = get_token_or_exit()
|
|
553
|
-
|
|
554
|
-
if args.contacts:
|
|
555
|
-
# Outlook contacts
|
|
556
|
-
params = {"$top": args.limit, "$select": "displayName,emailAddresses,mobilePhone,jobTitle,companyName"}
|
|
557
|
-
if args.search:
|
|
558
|
-
params["$search"] = f'"{args.search}"'
|
|
559
|
-
data = graph_get("/me/contacts", token, params)
|
|
560
|
-
contacts = data.get("value", [])
|
|
561
|
-
if args.json:
|
|
562
|
-
print_json(contacts)
|
|
563
|
-
return
|
|
564
|
-
print(f"\n{'Name':<30} {'Email':<35} {'Title'}")
|
|
565
|
-
print("─" * 80)
|
|
566
|
-
for c in contacts:
|
|
567
|
-
emails = [e["address"] for e in c.get("emailAddresses", [])]
|
|
568
|
-
email = emails[0] if emails else "N/A"
|
|
569
|
-
print(f"{c.get('displayName',''):<30} {email:<35} {c.get('jobTitle','')}")
|
|
570
|
-
return
|
|
571
|
-
|
|
572
|
-
# Relevant people (AI-ranked by interaction)
|
|
573
|
-
params = {"$top": args.limit}
|
|
574
|
-
if args.search:
|
|
575
|
-
params["$search"] = f'"{args.search}"'
|
|
576
|
-
data = graph_get("/me/people", token, params)
|
|
577
|
-
people = data.get("value", [])
|
|
578
|
-
|
|
579
|
-
if args.json:
|
|
580
|
-
print_json(people)
|
|
581
|
-
return
|
|
582
|
-
|
|
583
|
-
if not people:
|
|
584
|
-
print("No people found.")
|
|
585
|
-
return
|
|
586
|
-
|
|
587
|
-
print(f"\n{'Name':<30} {'Email':<35} {'Title'}")
|
|
588
|
-
print("─" * 80)
|
|
589
|
-
for p in people:
|
|
590
|
-
emails = [s["address"] for s in p.get("scoredEmailAddresses", [])]
|
|
591
|
-
email = emails[0] if emails else "N/A"
|
|
592
|
-
print(f"{p.get('displayName',''):<30} {email:<35} {p.get('jobTitle','')}")
|
|
593
|
-
|
|
594
|
-
def cmd_org(args):
|
|
595
|
-
"""Show organizational info: manager, reports, colleagues."""
|
|
596
|
-
token = get_token_or_exit()
|
|
597
|
-
|
|
598
|
-
if args.manager:
|
|
599
|
-
try:
|
|
600
|
-
mgr = graph_get("/me/manager", token)
|
|
601
|
-
print(f"Manager: {mgr.get('displayName')} <{mgr.get('userPrincipalName')}>")
|
|
602
|
-
print(f"Title : {mgr.get('jobTitle', 'N/A')}")
|
|
603
|
-
except requests.HTTPError as e:
|
|
604
|
-
if e.response.status_code == 404:
|
|
605
|
-
print("No manager found (you may be at the top of the org).")
|
|
606
|
-
else:
|
|
607
|
-
raise
|
|
608
|
-
return
|
|
609
|
-
|
|
610
|
-
if args.reports:
|
|
611
|
-
data = graph_get("/me/directReports", token,
|
|
612
|
-
{"$select": "displayName,userPrincipalName,jobTitle"})
|
|
613
|
-
reports = data.get("value", [])
|
|
614
|
-
print(f"\nDirect Reports ({len(reports)}):")
|
|
615
|
-
for r in reports:
|
|
616
|
-
print(f" {r.get('displayName'):<30} {r.get('userPrincipalName')}")
|
|
617
|
-
return
|
|
618
|
-
|
|
619
|
-
# Default: show org context
|
|
620
|
-
try:
|
|
621
|
-
mgr = graph_get("/me/manager", token)
|
|
622
|
-
print(f"Manager: {mgr.get('displayName')}")
|
|
623
|
-
except Exception:
|
|
624
|
-
pass
|
|
625
|
-
reports = graph_get("/me/directReports", token, {"$select": "displayName"}).get("value", [])
|
|
626
|
-
print(f"Direct Reports: {len(reports)}")
|
|
627
|
-
colleagues = graph_get("/me/people", token, {"$top": 5}).get("value", [])
|
|
628
|
-
print(f"\nFrequent colleagues:")
|
|
629
|
-
for p in colleagues:
|
|
630
|
-
print(f" {p.get('displayName')}")
|
|
631
|
-
|
|
632
|
-
# ── CLI Setup ─────────────────────────────────────────────────────────────────
|
|
633
|
-
def main():
|
|
634
|
-
parser = argparse.ArgumentParser(
|
|
635
|
-
description="Microsoft Graph API CLI",
|
|
636
|
-
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
637
|
-
epilog="""
|
|
638
|
-
Examples:
|
|
639
|
-
python msgraph.py login # Authenticate
|
|
640
|
-
python msgraph.py status # Check login status
|
|
641
|
-
python msgraph.py me # Show profile
|
|
642
|
-
python msgraph.py emails --limit 10 # List 10 emails
|
|
643
|
-
python msgraph.py emails --unread # Unread emails
|
|
644
|
-
python msgraph.py emails --search "invoice" # Search emails
|
|
645
|
-
python msgraph.py emails --read MSG_ID # Read specific email
|
|
646
|
-
python msgraph.py emails --send "a@b.com" --subject "Hi" --body "Hello"
|
|
647
|
-
python msgraph.py calendar --limit 10 # Upcoming events
|
|
648
|
-
python msgraph.py calendar --create "Meeting" --start 2024-03-15T10:00 --end 2024-03-15T11:00
|
|
649
|
-
python msgraph.py sharepoint --sites # List SharePoint sites
|
|
650
|
-
python msgraph.py teams --chats # List Teams chats
|
|
651
|
-
python msgraph.py teams --messages CHAT_ID # Read chat messages
|
|
652
|
-
python msgraph.py onedrive # List OneDrive root
|
|
653
|
-
python msgraph.py onedrive --path "Documents" # Browse folder
|
|
654
|
-
python msgraph.py onedrive --upload file.txt # Upload file
|
|
655
|
-
python msgraph.py people # Frequent contacts
|
|
656
|
-
python msgraph.py org --manager # Show your manager
|
|
657
|
-
"""
|
|
658
|
-
)
|
|
659
|
-
|
|
660
|
-
# Global flags
|
|
661
|
-
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
|
662
|
-
|
|
663
|
-
subparsers = parser.add_subparsers(dest="command")
|
|
664
|
-
|
|
665
|
-
# login
|
|
666
|
-
subparsers.add_parser("login", help="Authenticate with Microsoft")
|
|
667
|
-
|
|
668
|
-
# logout
|
|
669
|
-
subparsers.add_parser("logout", help="Remove cached credentials")
|
|
670
|
-
|
|
671
|
-
# status
|
|
672
|
-
subparsers.add_parser("status", help="Check login status")
|
|
673
|
-
|
|
674
|
-
# me
|
|
675
|
-
p_me = subparsers.add_parser("me", help="Show user profile")
|
|
676
|
-
p_me.add_argument("--json", action="store_true")
|
|
677
|
-
|
|
678
|
-
# emails
|
|
679
|
-
p_email = subparsers.add_parser("emails", help="Work with emails")
|
|
680
|
-
p_email.add_argument("--limit", type=int, default=10, help="Number of emails to show")
|
|
681
|
-
p_email.add_argument("--search", help="Search query")
|
|
682
|
-
p_email.add_argument("--unread", action="store_true", help="Show only unread emails")
|
|
683
|
-
p_email.add_argument("--folder", help="Mail folder (inbox, sentitems, drafts, etc.)")
|
|
684
|
-
p_email.add_argument("--read", metavar="ID", help="Read email by ID")
|
|
685
|
-
p_email.add_argument("--send", metavar="TO", help="Send email to address")
|
|
686
|
-
p_email.add_argument("--subject", help="Email subject (for --send)")
|
|
687
|
-
p_email.add_argument("--body", help="Email body text")
|
|
688
|
-
p_email.add_argument("--json", action="store_true")
|
|
689
|
-
|
|
690
|
-
# calendar
|
|
691
|
-
p_cal = subparsers.add_parser("calendar", help="Work with calendar")
|
|
692
|
-
p_cal.add_argument("--limit", type=int, default=10)
|
|
693
|
-
p_cal.add_argument("--create", metavar="TITLE", help="Create event with this title")
|
|
694
|
-
p_cal.add_argument("--start", help="Start datetime (YYYY-MM-DDTHH:MM)")
|
|
695
|
-
p_cal.add_argument("--end", help="End datetime (YYYY-MM-DDTHH:MM)")
|
|
696
|
-
p_cal.add_argument("--location", help="Event location")
|
|
697
|
-
p_cal.add_argument("--body", help="Event description")
|
|
698
|
-
p_cal.add_argument("--timezone", default="UTC", help="Timezone (e.g. Europe/Berlin)")
|
|
699
|
-
p_cal.add_argument("--availability", action="store_true", help="Check free/busy")
|
|
700
|
-
p_cal.add_argument("--json", action="store_true")
|
|
701
|
-
|
|
702
|
-
# sharepoint
|
|
703
|
-
p_sp = subparsers.add_parser("sharepoint", help="Browse SharePoint")
|
|
704
|
-
p_sp.add_argument("--sites", action="store_true", help="List followed sites")
|
|
705
|
-
p_sp.add_argument("--site", metavar="SITE_ID", help="Browse files in site")
|
|
706
|
-
p_sp.add_argument("--path", help="Path within site drive")
|
|
707
|
-
p_sp.add_argument("--download", metavar="ITEM_ID", help="Download file by ID")
|
|
708
|
-
p_sp.add_argument("--output", help="Output file path")
|
|
709
|
-
p_sp.add_argument("--limit", type=int, default=20)
|
|
710
|
-
p_sp.add_argument("--json", action="store_true")
|
|
711
|
-
|
|
712
|
-
# teams
|
|
713
|
-
p_teams = subparsers.add_parser("teams", help="Work with Teams")
|
|
714
|
-
p_teams.add_argument("--chats", action="store_true", help="List all chats")
|
|
715
|
-
p_teams.add_argument("--messages", metavar="CHAT_ID", help="Get messages from chat")
|
|
716
|
-
p_teams.add_argument("--send", metavar="TEXT", help="Send a message")
|
|
717
|
-
p_teams.add_argument("--chat-id", dest="chat_id", help="Target chat ID for --send")
|
|
718
|
-
p_teams.add_argument("--teams-list", action="store_true", help="List joined teams")
|
|
719
|
-
p_teams.add_argument("--limit", type=int, default=20)
|
|
720
|
-
p_teams.add_argument("--json", action="store_true")
|
|
721
|
-
|
|
722
|
-
# onedrive
|
|
723
|
-
p_od = subparsers.add_parser("onedrive", help="Work with OneDrive")
|
|
724
|
-
p_od.add_argument("--path", help="Folder path to browse")
|
|
725
|
-
p_od.add_argument("--upload", metavar="FILE", help="Upload a local file")
|
|
726
|
-
p_od.add_argument("--dest", help="Destination path in OneDrive (for --upload)")
|
|
727
|
-
p_od.add_argument("--download", metavar="ITEM_ID", help="Download by item ID")
|
|
728
|
-
p_od.add_argument("--output", help="Output file path")
|
|
729
|
-
p_od.add_argument("--info", metavar="ITEM_ID", help="Get file metadata")
|
|
730
|
-
p_od.add_argument("--limit", type=int, default=20)
|
|
731
|
-
p_od.add_argument("--json", action="store_true")
|
|
732
|
-
|
|
733
|
-
# people
|
|
734
|
-
p_ppl = subparsers.add_parser("people", help="Browse people and contacts")
|
|
735
|
-
p_ppl.add_argument("--contacts", action="store_true", help="Use Outlook contacts (not people)")
|
|
736
|
-
p_ppl.add_argument("--search", help="Search by name")
|
|
737
|
-
p_ppl.add_argument("--limit", type=int, default=20)
|
|
738
|
-
p_ppl.add_argument("--json", action="store_true")
|
|
739
|
-
|
|
740
|
-
# org
|
|
741
|
-
p_org = subparsers.add_parser("org", help="Organizational info")
|
|
742
|
-
p_org.add_argument("--manager", action="store_true", help="Show your manager")
|
|
743
|
-
p_org.add_argument("--reports", action="store_true", help="Show direct reports")
|
|
744
|
-
p_org.add_argument("--json", action="store_true")
|
|
745
|
-
|
|
746
|
-
args = parser.parse_args()
|
|
747
|
-
|
|
748
|
-
if not args.command:
|
|
749
|
-
parser.print_help()
|
|
750
|
-
sys.exit(0)
|
|
751
|
-
|
|
752
|
-
command_map = {
|
|
753
|
-
"login": cmd_login,
|
|
754
|
-
"logout": cmd_logout,
|
|
755
|
-
"status": cmd_status,
|
|
756
|
-
"me": cmd_me,
|
|
757
|
-
"emails": cmd_emails,
|
|
758
|
-
"calendar": cmd_calendar,
|
|
759
|
-
"sharepoint": cmd_sharepoint,
|
|
760
|
-
"teams": cmd_teams,
|
|
761
|
-
"onedrive": cmd_onedrive,
|
|
762
|
-
"people": cmd_people,
|
|
763
|
-
"org": cmd_org,
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
try:
|
|
767
|
-
command_map[args.command](args)
|
|
768
|
-
except requests.HTTPError as e:
|
|
769
|
-
status = e.response.status_code
|
|
770
|
-
if status == 401:
|
|
771
|
-
print("Error: Authentication expired. Run: python msgraph.py login")
|
|
772
|
-
elif status == 403:
|
|
773
|
-
print(f"Error: Permission denied for this operation ({e.response.url})")
|
|
774
|
-
print("You may need additional OAuth scopes.")
|
|
775
|
-
elif status == 404:
|
|
776
|
-
print(f"Error: Resource not found ({e.response.url})")
|
|
777
|
-
else:
|
|
778
|
-
print(f"HTTP Error {status}: {e.response.text[:200]}")
|
|
779
|
-
sys.exit(1)
|
|
780
|
-
except KeyboardInterrupt:
|
|
781
|
-
print("\nCancelled.")
|
|
782
|
-
sys.exit(0)
|
|
783
|
-
|
|
784
|
-
if __name__ == "__main__":
|
|
785
|
-
main()
|