@codemieai/code 0.0.46 → 0.0.47
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 +183 -0
- package/dist/agents/plugins/claude/plugin/skills/msgraph/SKILL.md +233 -0
- package/dist/agents/plugins/claude/plugin/skills/msgraph/scripts/msgraph.py +785 -0
- package/package.json +1 -1
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# Microsoft Graph API Skill for Claude Code
|
|
2
|
+
|
|
3
|
+
Work with your Microsoft 365 account from Claude Code — emails, calendar, SharePoint, Teams, OneDrive, contacts, and org chart — all via the Microsoft Graph API.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
### 1. Install dependencies
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install msal requests
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
### 2. Log in (first time only)
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
python .codemie/claude-plugin/skills/msgraph/msgraph.py login
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
You'll see a message like:
|
|
22
|
+
```
|
|
23
|
+
==============================
|
|
24
|
+
To sign in, use a web browser to open the page https://microsoft.com/devicelogin
|
|
25
|
+
and enter the code ABCD-1234 to authenticate.
|
|
26
|
+
==============================
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Open the URL in your browser, enter the code, and sign in with your Microsoft account.
|
|
30
|
+
Your token is cached at `~/.ms_graph_token_cache.json` — you won't need to log in again until it expires (tokens refresh automatically).
|
|
31
|
+
|
|
32
|
+
### 3. Ask Claude anything
|
|
33
|
+
|
|
34
|
+
After logging in, just ask Claude naturally:
|
|
35
|
+
|
|
36
|
+
- *"Show me my unread emails"*
|
|
37
|
+
- *"What meetings do I have this week?"*
|
|
38
|
+
- *"Find the Q4 report in SharePoint"*
|
|
39
|
+
- *"What did Alice send me in Teams?"*
|
|
40
|
+
- *"Who reports to me?"*
|
|
41
|
+
|
|
42
|
+
Claude will use this skill automatically.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## CLI Reference
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
python .codemie/claude-plugin/skills/msgraph/msgraph.py <command> [options]
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Auth commands
|
|
53
|
+
|
|
54
|
+
| Command | Description |
|
|
55
|
+
|---------|-------------|
|
|
56
|
+
| `login` | Authenticate (device code flow) |
|
|
57
|
+
| `logout` | Remove cached credentials |
|
|
58
|
+
| `status` | Check if you're logged in |
|
|
59
|
+
|
|
60
|
+
### Data commands
|
|
61
|
+
|
|
62
|
+
| Command | Key options | Description |
|
|
63
|
+
|---------|-------------|-------------|
|
|
64
|
+
| `me` | | Your profile |
|
|
65
|
+
| `emails` | `--limit N`, `--unread`, `--search QUERY`, `--read ID`, `--send TO --subject S --body B`, `--folder NAME` | Work with Outlook emails |
|
|
66
|
+
| `calendar` | `--limit N`, `--create TITLE --start DT --end DT`, `--availability` | Calendar events |
|
|
67
|
+
| `sharepoint` | `--sites`, `--site ID [--path P]`, `--download ID` | SharePoint files |
|
|
68
|
+
| `teams` | `--chats`, `--messages CHAT_ID`, `--send MSG --chat-id ID`, `--teams-list` | Teams messages |
|
|
69
|
+
| `onedrive` | `[--path P]`, `--upload FILE`, `--download ID`, `--info ID` | OneDrive files |
|
|
70
|
+
| `people` | `--search NAME`, `--contacts` | People & contacts |
|
|
71
|
+
| `org` | `--manager`, `--reports` | Org chart |
|
|
72
|
+
|
|
73
|
+
Add `--json` to any command for machine-readable JSON output.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Examples
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
# List 20 most recent emails
|
|
81
|
+
python .codemie/claude-plugin/skills/msgraph/msgraph.py emails --limit 20
|
|
82
|
+
|
|
83
|
+
# Read a specific email (paste the ID from list output)
|
|
84
|
+
python .codemie/claude-plugin/skills/msgraph/msgraph.py emails --read AAMkAGI2...
|
|
85
|
+
|
|
86
|
+
# Send an email
|
|
87
|
+
python .codemie/claude-plugin/skills/msgraph/msgraph.py emails \
|
|
88
|
+
--send colleague@company.com \
|
|
89
|
+
--subject "Quick question" \
|
|
90
|
+
--body "Are you free tomorrow at 2pm?"
|
|
91
|
+
|
|
92
|
+
# Upcoming calendar events
|
|
93
|
+
python .codemie/claude-plugin/skills/msgraph/msgraph.py calendar --limit 5
|
|
94
|
+
|
|
95
|
+
# Create a meeting
|
|
96
|
+
python .codemie/claude-plugin/skills/msgraph/msgraph.py calendar \
|
|
97
|
+
--create "Design Review" \
|
|
98
|
+
--start "2024-03-20T14:00" \
|
|
99
|
+
--end "2024-03-20T15:00" \
|
|
100
|
+
--timezone "Europe/Berlin"
|
|
101
|
+
|
|
102
|
+
# List SharePoint sites you follow
|
|
103
|
+
python .codemie/claude-plugin/skills/msgraph/msgraph.py sharepoint --sites
|
|
104
|
+
|
|
105
|
+
# Browse a site's documents
|
|
106
|
+
python .codemie/claude-plugin/skills/msgraph/msgraph.py sharepoint \
|
|
107
|
+
--site "contoso.sharepoint.com,abc123,def456" \
|
|
108
|
+
--path "Documents/2024"
|
|
109
|
+
|
|
110
|
+
# Download a OneDrive file
|
|
111
|
+
python .codemie/claude-plugin/skills/msgraph/msgraph.py onedrive --download ITEM_ID --output report.xlsx
|
|
112
|
+
|
|
113
|
+
# Upload to OneDrive
|
|
114
|
+
python .codemie/claude-plugin/skills/msgraph/msgraph.py onedrive \
|
|
115
|
+
--upload ./presentation.pptx \
|
|
116
|
+
--dest "Documents/presentations/deck.pptx"
|
|
117
|
+
|
|
118
|
+
# Recent Teams chats
|
|
119
|
+
python .codemie/claude-plugin/skills/msgraph/msgraph.py teams --chats
|
|
120
|
+
|
|
121
|
+
# Read a chat conversation
|
|
122
|
+
python .codemie/claude-plugin/skills/msgraph/msgraph.py teams --messages 19:abc123@thread.v2
|
|
123
|
+
|
|
124
|
+
# Search your contacts
|
|
125
|
+
python .codemie/claude-plugin/skills/msgraph/msgraph.py people --search "John"
|
|
126
|
+
|
|
127
|
+
# Your manager
|
|
128
|
+
python .codemie/claude-plugin/skills/msgraph/msgraph.py org --manager
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Token Cache
|
|
134
|
+
|
|
135
|
+
Credentials are stored in `~/.ms_graph_token_cache.json`.
|
|
136
|
+
- Access tokens refresh automatically (they last ~1 hour, but MSAL handles renewal)
|
|
137
|
+
- Refresh tokens last longer (~90 days by default in Azure)
|
|
138
|
+
- Run `logout` to remove the cache: `python msgraph.py logout`
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Permissions
|
|
143
|
+
|
|
144
|
+
The script requests these Microsoft Graph scopes on first login:
|
|
145
|
+
|
|
146
|
+
| Scope | Used for |
|
|
147
|
+
|-------|---------|
|
|
148
|
+
| `User.Read` | Profile (`me`, `org`) |
|
|
149
|
+
| `Mail.Read` | Reading emails |
|
|
150
|
+
| `Mail.Send` | Sending emails |
|
|
151
|
+
| `Calendars.Read` / `Calendars.ReadWrite` | Calendar events |
|
|
152
|
+
| `Files.Read` / `Files.ReadWrite` | OneDrive files |
|
|
153
|
+
| `Sites.Read.All` | SharePoint sites |
|
|
154
|
+
| `Chat.Read` / `Chat.ReadWrite` | Teams chats |
|
|
155
|
+
| `People.Read` | People rankings |
|
|
156
|
+
| `Contacts.Read` | Outlook contacts |
|
|
157
|
+
|
|
158
|
+
If your organization restricts some permissions, certain commands may return `Permission denied`.
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## Troubleshooting
|
|
163
|
+
|
|
164
|
+
**`ModuleNotFoundError: No module named 'msal'`**
|
|
165
|
+
```bash
|
|
166
|
+
pip install msal requests
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
**`NOT_LOGGED_IN`**
|
|
170
|
+
```bash
|
|
171
|
+
python .codemie/claude-plugin/skills/msgraph/msgraph.py login
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
**`Permission denied (403)`**
|
|
175
|
+
Your organization may have restricted that Graph API permission. Contact your IT admin.
|
|
176
|
+
|
|
177
|
+
**`Authentication expired (401)`**
|
|
178
|
+
```bash
|
|
179
|
+
python .codemie/claude-plugin/skills/msgraph/msgraph.py login
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
**Token cache is at wrong path**
|
|
183
|
+
Edit `CACHE_FILE` at the top of `msgraph.py` to change the location.
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: msgraph
|
|
3
|
+
description: >
|
|
4
|
+
Work with Microsoft 365 services via the Graph API — emails, calendar events, SharePoint sites,
|
|
5
|
+
Teams chats, OneDrive files, contacts, and org chart. Use this skill whenever the user asks
|
|
6
|
+
about their emails, inbox, unread messages, meetings, calendar, Teams messages or chats,
|
|
7
|
+
SharePoint documents, OneDrive files, colleagues, manager, direct reports, or any personal/
|
|
8
|
+
organizational Microsoft data. Invoke proactively any time the user mentions Outlook, Teams,
|
|
9
|
+
SharePoint, OneDrive, or wants to interact with their Microsoft 365 account. The skill uses
|
|
10
|
+
a local Python CLI (msgraph.py) that handles authentication, token caching, and all API calls.
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Microsoft Graph API Skill
|
|
14
|
+
|
|
15
|
+
This skill lets you interact with Microsoft 365 services on behalf of the user using the
|
|
16
|
+
Microsoft Graph API. The Python CLI at `scripts/msgraph.py` handles everything.
|
|
17
|
+
|
|
18
|
+
## Setup & Authentication
|
|
19
|
+
|
|
20
|
+
**Check login status first** — always run this before any other command:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
python scripts/msgraph.py status
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
**Output interpretation:**
|
|
27
|
+
- `Logged in as: user@company.com` → proceed with any command below
|
|
28
|
+
- `NOT_LOGGED_IN` → follow the Login Flow below
|
|
29
|
+
- `TOKEN_EXPIRED` → session expired, also follow the Login Flow below
|
|
30
|
+
|
|
31
|
+
### Login Flow (first time or after expiry)
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
python scripts/msgraph.py login
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
This starts the **Device Code Flow** — it will print a URL and a short code like:
|
|
38
|
+
```
|
|
39
|
+
To sign in, use a web browser to open the page https://microsoft.com/devicelogin
|
|
40
|
+
and enter the code ABCD-1234 to authenticate.
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Tell the user exactly that message, then wait. Once they complete login in the browser,
|
|
44
|
+
the token is cached at `~/.ms_graph_token_cache.json` and all subsequent commands run silently.
|
|
45
|
+
|
|
46
|
+
### When NOT logged in or token expired
|
|
47
|
+
|
|
48
|
+
If status returns `NOT_LOGGED_IN` or `TOKEN_EXPIRED`, tell the user:
|
|
49
|
+
|
|
50
|
+
> "You need to log in to Microsoft first. Run this command in your terminal:
|
|
51
|
+
> ```
|
|
52
|
+
> python scripts/msgraph.py login
|
|
53
|
+
> ```
|
|
54
|
+
> A code and URL will appear — open the URL in your browser and enter the code."
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Available Commands
|
|
59
|
+
|
|
60
|
+
### Profile & Org
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# Your profile
|
|
64
|
+
python scripts/msgraph.py me
|
|
65
|
+
|
|
66
|
+
# Your manager
|
|
67
|
+
python scripts/msgraph.py org --manager
|
|
68
|
+
|
|
69
|
+
# Your direct reports
|
|
70
|
+
python scripts/msgraph.py org --reports
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Emails
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
# List recent emails (default 10)
|
|
77
|
+
python scripts/msgraph.py emails
|
|
78
|
+
|
|
79
|
+
# More emails
|
|
80
|
+
python scripts/msgraph.py emails --limit 25
|
|
81
|
+
|
|
82
|
+
# Unread only
|
|
83
|
+
python scripts/msgraph.py emails --unread
|
|
84
|
+
|
|
85
|
+
# Search emails
|
|
86
|
+
python scripts/msgraph.py emails --search "invoice Q4"
|
|
87
|
+
|
|
88
|
+
# Read a specific email by ID (copy ID from list output)
|
|
89
|
+
python scripts/msgraph.py emails --read MESSAGE_ID
|
|
90
|
+
|
|
91
|
+
# Send an email
|
|
92
|
+
python scripts/msgraph.py emails --send recipient@example.com --subject "Hello" --body "Message text"
|
|
93
|
+
|
|
94
|
+
# Browse specific folder (inbox, sentitems, drafts, deleteditems, junkemail)
|
|
95
|
+
python scripts/msgraph.py emails --folder sentitems --limit 5
|
|
96
|
+
|
|
97
|
+
# Machine-readable JSON output
|
|
98
|
+
python scripts/msgraph.py emails --json
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Calendar
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
# Upcoming events (default 10)
|
|
105
|
+
python scripts/msgraph.py calendar
|
|
106
|
+
|
|
107
|
+
# More events
|
|
108
|
+
python scripts/msgraph.py calendar --limit 20
|
|
109
|
+
|
|
110
|
+
# Create an event
|
|
111
|
+
python scripts/msgraph.py calendar --create "Team Standup" \
|
|
112
|
+
--start "2024-03-20T09:00" --end "2024-03-20T09:30" \
|
|
113
|
+
--location "Teams" --timezone "Europe/Berlin"
|
|
114
|
+
|
|
115
|
+
# Check availability for a time window
|
|
116
|
+
python scripts/msgraph.py calendar --availability \
|
|
117
|
+
--start "2024-03-20T09:00:00" --end "2024-03-20T18:00:00"
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### SharePoint
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
# List followed/joined SharePoint sites
|
|
124
|
+
python scripts/msgraph.py sharepoint --sites
|
|
125
|
+
|
|
126
|
+
# Browse files in a specific site (use ID from --sites output)
|
|
127
|
+
python scripts/msgraph.py sharepoint --site SITE_ID
|
|
128
|
+
|
|
129
|
+
# Browse a subfolder within a site
|
|
130
|
+
python scripts/msgraph.py sharepoint --site SITE_ID --path "Documents/Reports"
|
|
131
|
+
|
|
132
|
+
# Download a file
|
|
133
|
+
python scripts/msgraph.py sharepoint --download ITEM_ID --output report.xlsx
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Teams
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
# List all Teams chats
|
|
140
|
+
python scripts/msgraph.py teams --chats
|
|
141
|
+
|
|
142
|
+
# Read messages from a chat (use chat ID from --chats output)
|
|
143
|
+
python scripts/msgraph.py teams --messages CHAT_ID
|
|
144
|
+
|
|
145
|
+
# Send a message to a chat
|
|
146
|
+
python scripts/msgraph.py teams --send "Hello team!" --chat-id CHAT_ID
|
|
147
|
+
|
|
148
|
+
# List teams you're a member of
|
|
149
|
+
python scripts/msgraph.py teams --teams-list
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### OneDrive
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
# List root files
|
|
156
|
+
python scripts/msgraph.py onedrive
|
|
157
|
+
|
|
158
|
+
# Browse a folder
|
|
159
|
+
python scripts/msgraph.py onedrive --path "Documents"
|
|
160
|
+
|
|
161
|
+
# Upload a file
|
|
162
|
+
python scripts/msgraph.py onedrive --upload ./report.pdf --dest "Documents/report.pdf"
|
|
163
|
+
|
|
164
|
+
# Download a file by ID
|
|
165
|
+
python scripts/msgraph.py onedrive --download ITEM_ID --output local_copy.pdf
|
|
166
|
+
|
|
167
|
+
# File metadata
|
|
168
|
+
python scripts/msgraph.py onedrive --info ITEM_ID
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### People & Contacts
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
# Frequent collaborators (AI-ranked by Microsoft)
|
|
175
|
+
python scripts/msgraph.py people
|
|
176
|
+
|
|
177
|
+
# Search people by name
|
|
178
|
+
python scripts/msgraph.py people --search "Alice"
|
|
179
|
+
|
|
180
|
+
# Outlook address book contacts
|
|
181
|
+
python scripts/msgraph.py people --contacts
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Workflow Patterns
|
|
187
|
+
|
|
188
|
+
### "Show me my emails"
|
|
189
|
+
1. Run `status` → check login
|
|
190
|
+
2. Run `emails --limit 15` → show results
|
|
191
|
+
3. If user wants to read one, run `emails --read ID`
|
|
192
|
+
|
|
193
|
+
### "What's on my calendar today/this week?"
|
|
194
|
+
1. Run `calendar --limit 10`
|
|
195
|
+
2. Parse dates in output and filter for user's timeframe
|
|
196
|
+
|
|
197
|
+
### "Find a file in SharePoint"
|
|
198
|
+
1. Run `sharepoint --sites` → list sites
|
|
199
|
+
2. Run `sharepoint --site SITE_ID` → browse files
|
|
200
|
+
3. Use `--path` to drill into folders
|
|
201
|
+
4. Offer `--download ITEM_ID` if user wants the file
|
|
202
|
+
|
|
203
|
+
### "Check my Teams messages"
|
|
204
|
+
1. Run `teams --chats` → list chats
|
|
205
|
+
2. User picks a chat → run `teams --messages CHAT_ID`
|
|
206
|
+
|
|
207
|
+
### "Who's my manager?" / "Who reports to me?"
|
|
208
|
+
- Run `org --manager` or `org --reports`
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## Error Handling
|
|
213
|
+
|
|
214
|
+
| Exit code | Meaning |
|
|
215
|
+
|-----------|---------|
|
|
216
|
+
| 0 | Success |
|
|
217
|
+
| 1 | API error (shown in output) |
|
|
218
|
+
| 2 | NOT_LOGGED_IN — user must run `login` |
|
|
219
|
+
|
|
220
|
+
When you see `Permission denied` errors, it means the OAuth scope isn't granted for that operation.
|
|
221
|
+
This can happen if the user's organization has restricted certain Graph API permissions.
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## Dependencies
|
|
226
|
+
|
|
227
|
+
The script requires Python 3.10+ and two packages:
|
|
228
|
+
```bash
|
|
229
|
+
pip install msal requests
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
If `msal` or `requests` are not installed, the script prints the install command and exits.
|
|
233
|
+
IMPORTANT: you must work with current date (get it from sh/bash)
|
|
@@ -0,0 +1,785 @@
|
|
|
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()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@codemieai/code",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.47",
|
|
4
4
|
"description": "Unified AI coding assistant CLI - Manage Claude Code, Gemini & custom agents. Multi-provider support (OpenAI, Azure, LiteLLM, SSO). Built-in LangGraph agent with file operations & git integration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|