Skill: trendex/trendex-onedrive (version 1.0.0)

--- name: onedrive description: Read, manage, share, upload, and download OneDrive files via Microsoft Graph API. Use when the user asks about OneDrive, OneDrive for Business, SharePoint document libraries, file sharing, or cloud storage on Microsoft. version: 1.0.0 author: trendex --- # OneDrive Skill Access OneDrive (personal + business) and SharePoint document libraries via Microsoft Graph using OAuth2. ## Quick Setup (Automated) ```bash # Requires: Azure CLI, jq ./scripts/onedrive-setup.sh ``` The setup script will: 1. Log you into Azure (device code flow) 2. Create an App Registration automatically 3. Configure API permissions (Files.ReadWrite.All, Sites.ReadWrite.All, User.Read) 4. Guide you through authorization 5. Save credentials to `~/.onedrive-mcp/` ## Manual Setup See `references/setup.md` for step-by-step manual configuration via Azure Portal. ## Bootstrap (Headless / Pre-Provisioned Tokens) If the deployment system already has `client_id`, `client_secret`, `access_token`, and `refresh_token`, run the templated bootstrap script (substituting the `__PLACEHOLDERS__`): ```bash bash scripts/onedrive-bootstrap.sh ``` It installs `jq` + `curl` if missing, writes `~/.onedrive-mcp/{config,credentials}.json`, refreshes the token, and probes `/me/drive` to confirm. No browser, no Azure CLI. ## Usage ### Token Management ```bash ./scripts/onedrive-token.sh refresh # Refresh expired token ./scripts/onedrive-token.sh test # Test connection ./scripts/onedrive-token.sh get # Print access token ./scripts/onedrive-token.sh info # Show token info / expiry ./scripts/onedrive-token.sh me # Show signed-in user ``` ### Browsing Files ```bash ./scripts/onedrive-files.sh list # List root folder ./scripts/onedrive-files.sh list "Documents" # List a folder by path ./scripts/onedrive-files.sh list-id <item-id> # List by item ID ./scripts/onedrive-files.sh list-special documents # documents|photos|cameraroll|approot|music ./scripts/onedrive-files.sh tree [path] [depth=3] # Recursive tree ./scripts/onedrive-files.sh recent [count] # Recently used files ./scripts/onedrive-files.sh shared [count] # Items shared with me ./scripts/onedrive-files.sh search "<query>" [top] # Full-text search ``` ### Inspecting Items ```bash ./scripts/onedrive-files.sh info <path-or-id> # Full metadata ./scripts/onedrive-files.sh stat <path-or-id> # Size, mime, modified ./scripts/onedrive-files.sh url <path-or-id> # Pre-auth download URL ./scripts/onedrive-files.sh thumbnail <id> [size] # small | medium | large ./scripts/onedrive-files.sh preview <id> # Embeddable preview URL ./scripts/onedrive-files.sh versions <id> # Version history ./scripts/onedrive-files.sh owner <path-or-id> # Creator / last modifier ``` ### Uploading / Downloading ```bash ./scripts/onedrive-files.sh mkdir <path> # Create folder (parents auto-created) ./scripts/onedrive-files.sh upload <local> <remote> # Auto: simple (≤4MB) or resumable ./scripts/onedrive-files.sh upload-stream <remote> # Pipe stdin to remote ./scripts/onedrive-files.sh download <remote> [local] ./scripts/onedrive-files.sh cat <path> # Print text file to stdout ``` ### Modifying / Deleting ```bash ./scripts/onedrive-files.sh rename <id-or-path> <new-name> ./scripts/onedrive-files.sh move <src> <dest-folder> ./scripts/onedrive-files.sh copy <src> <dest-folder> [new-name] ./scripts/onedrive-files.sh delete <id-or-path> # → recycle bin ``` ### Sharing ```bash # Anonymous / org-scoped links ./scripts/onedrive-share.sh link <path-or-id> view anonymous ./scripts/onedrive-share.sh link <path-or-id> edit anonymous ./scripts/onedrive-share.sh link <path-or-id> view organization ./scripts/onedrive-share.sh link <path-or-id> embed anonymous ./scripts/onedrive-share.sh link <path-or-id> view anonymous --password "secret" --expiry 2026-12-31 # Invite specific users ./scripts/onedrive-share.sh invite <path-or-id> [REDACTED_SECRET] read "Have a look!" ./scripts/onedrive-share.sh invite <path-or-id> [REDACTED_SECRET],[REDACTED_SECRET] write "Please review" # Permissions ./scripts/onedrive-share.sh permissions <path-or-id> # List ./scripts/onedrive-share.sh revoke <path-or-id> <perm-id> ./scripts/onedrive-share.sh update-role <path-or-id> <perm-id> <new-role> # Resolve a 1drv.ms / SharePoint share URL ./scripts/onedrive-share.sh open "https://1drv.ms/u/s!..." ``` ### Drives & Quota ```bash ./scripts/onedrive-files.sh drives # List drives the user can access ./scripts/onedrive-files.sh drive [drive-id] # Drive metadata (default: yours) ./scripts/onedrive-files.sh quota # total / used / remaining ./scripts/onedrive-files.sh delta [token] # Change feed (initial or incremental) ``` ### Example Output ```bash $ ./scripts/onedrive-files.sh list { "kind": "dir", "name": "Documents", "size": 5242880, "modified": "2026-05-15T10:24:31Z", "id": "01ABCDEF1234ZZZ", "mime": null, "children": 12 } { "kind": "file", "name": "Budget-Q2.xlsx", "size": 184320, "modified": "2026-05-18T16:02:11Z", "id": "01ABCDEF5678YYY", "mime": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" } $ ./scripts/onedrive-files.sh quota { "state": "normal", "total": 1099511627776, "used": 482918400, "remaining": 1099028709376, "deleted": 0 } $ ./scripts/onedrive-share.sh link "Reports/q1.xlsx" view organization { "id": "aTo...", "type": "view", "scope": "organization", "webUrl": "https://contoso-my.sharepoint.com/:x:/p/...", "expirationDateTime": null, "hasPassword": false } $ ./scripts/onedrive-token.sh me { "id": "9b8...-...-...", "displayName": "Jane Doe", "userPrincipalName": "[REDACTED_SECRET]", "mail": "[REDACTED_SECRET]", "givenName": "Jane", "surname": "Doe", "jobTitle": "Engineer" } ``` ## Token Refresh Access tokens expire after ~1 hour. Refresh with: ```bash ./scripts/onedrive-token.sh refresh ``` This requires a valid `refresh_token` in the credentials file (kept ~90 days for personal accounts). ## Files - `~/.onedrive-mcp/config.json` — Client ID, secret, tenant, scopes - `~/.onedrive-mcp/credentials.json` — OAuth tokens (access + refresh) Both files are `chmod 600`, directory `700`. Never commit them. ## Permissions Delegated scopes used by default: - `Files.ReadWrite.All` — Read/write all files the user can access - `Sites.ReadWrite.All` — SharePoint / OneDrive for Business document libraries - `User.Read` — Basic profile (needed for sign-in) - `offline_access` — Refresh tokens (stay logged in) See `references/permissions.md` for the full scope catalog (delegated + application/daemon flows). ## Targeting Other Drives By default, all commands target `/me/drive` (the signed-in user's drive). To target a different drive, set: ```bash export ONEDRIVE_DRIVE_PREFIX="/drives/<drive-id>" # Specific drive by ID export ONEDRIVE_DRIVE_PREFIX="/sites/<site-id>/drive" # SharePoint site default doc library export ONEDRIVE_DRIVE_PREFIX="/users/<user-id>/drive" # Another user (requires Files.Read.All) ``` Discover drive IDs with `./scripts/onedrive-files.sh drives`. ## Bring Your Own Access Token If you already have a Microsoft Graph access token (from another app / Postman / MSAL / etc.), three options: ```bash # A) Env var (one-shot) export ONEDRIVE_ACCESS_TOKEN="eyJ0..." ./scripts/onedrive-files.sh list # B) Helper (persists to ~/.onedrive-mcp/credentials.json) ./scripts/onedrive-token.sh set "eyJ0..." "refresh-token" # C) Drop the JSON file directly mkdir -p ~/.onedrive-mcp && chmod 700 ~/.onedrive-mcp cat > ~/.onedrive-mcp/credentials.json <<EOF {"token_type":"Bearer","access_token":"eyJ0...","refresh_token":"..."} EOF chmod 600 ~/.onedrive-mcp/credentials.json ``` ## Notes - **Item IDs**: stable per drive. The `id` field is the full Graph ID — pass it as-is. - **Path addressing**: use `/` separators (`Documents/Reports/q4.xlsx`). Scripts handle URL-encoding. - **Conflict behavior**: uploads default to `replace`. Use the API directly for `rename` / `fail`. - **Large uploads**: files > 4 MiB automatically use a resumable upload session (10 MiB chunks). - **Download URLs**: `@microsoft.graph.downloadUrl` returned by the API is pre-authenticated and short-lived (minutes). - **Deletes** move to the recycle bin (recoverable via the web UI for 30 days personal / 93 days business). - **Throttling**: respect the `Retry-After` header on `429`. ## Troubleshooting **"Token expired"** → `./scripts/onedrive-token.sh refresh` **"Invalid grant"** → Refresh token revoked or expired; re-run `onedrive-setup.sh` (or have the deployment system re-bootstrap). **"accessDenied"** → Token's scopes don't cover the operation. Check `onedrive-token.sh info` and reconsent with the right scopes. **"itemNotFound" on a visible path** → URL-encode special chars. Try `info <id>` to confirm the ID-based path works. **SharePoint drive returns 404 for `/me/drive`** → Use `/sites/{site-id}/drive` or list `/me/drives` first. **Resumable upload stalls** → The `uploadUrl` expired (~1h). Restart the session via `upload` (the script re-creates it automatically). ## Supported Accounts - Personal Microsoft accounts (`outlook.com`, `hotmail.com`, `live.com`) - Work / school accounts (Microsoft 365) — may require admin consent for `*.All` scopes - SharePoint document libraries via `Sites.ReadWrite.All` ## Resources - [Microsoft Graph OneDrive overview](https://learn.microsoft.com/en-us/graph/api/resources/onedrive) - [DriveItem resource](https://learn.microsoft.com/en-us/graph/api/resources/driveitem) - [Large file upload (resumable)](https://learn.microsoft.com/en-us/graph/api/driveitem-createuploadsession) - [Sharing concepts](https://learn.microsoft.com/en-us/graph/api/resources/sharing) - `references/api-reference.md` — full endpoint catalog - `references/permissions.md` — OAuth scopes (delegated + application) - `references/setup.md` — manual Azure Portal walkthrough ## Changelog ### v1.0.0 - Initial release - Direct Microsoft Graph integration (no third-party proxy) - Three install paths: automated (Azure CLI), manual (portal), or headless bootstrap (pre-provisioned tokens) - File/folder CRUD, sharing, permissions, search, recent, shared-with-me, versions, thumbnails, preview, delta - BYO-token support via `ONEDRIVE_ACCESS_TOKEN` or `onedrive-token.sh set` - Multi-drive targeting via `ONEDRIVE_DRIVE_PREFIX`

path: _meta.json
content: slug: onedrive
version: 1.0.0
publishedAt: 1779494400000

path: references/api-reference.md
content: # Microsoft Graph OneDrive — Full API Reference

All paths are relative to `https://graph.microsoft.com/v1.0` unless noted. Send `Authorization: Bearer <access_token>` on every request.

> The `v1.0` endpoint is the stable production API. Some advanced features (richer permissions, retention labels, large-list pagination tweaks) live at `https://graph.microsoft.com/beta`. The auth and tokens are identical.

---

## Common addressing

### Drive prefixes

| Prefix | Meaning |
|--------|---------|
| `/me/drive` | Current user's default drive |
| `/drives/{drive-id}` | Any drive by ID |
| `/users/{user-id}/drive` | Another user's drive (needs `Files.Read.All`) |
| `/groups/{group-id}/drive` | A Microsoft 365 group's drive |
| `/sites/{site-id}/drive` | Default doc library of a SharePoint site |
| `/sites/{site-id}/drives/{drive-id}` | A specific doc library on a site |

All of the operations below work under any of these prefixes — just replace `/me/drive` with the prefix that targets the drive you want.

### Item addressing

```
By ID    → /me/drive/items/{item-id}
By path  → /me/drive/root:/path/to/file.ext
Root     → /me/drive/root
Special  → /me/drive/special/{name}     ; name ∈ {documents, photos, cameraroll, approot, music}
Hybrid   → /me/drive/items/{id}:/relative/path
```

The colon (`:`) marks the boundary between the addressing and the action. Examples:

- List children of a folder by path: `/me/drive/root:/Documents:/children`
- Get content of a file by path: `/me/drive/root:/Documents/file.txt:/content`

Special characters in paths must be percent-encoded (`#` → `%23`, `?` → `%3F`).

---

## Drives

### Get default drive

```
GET /me/drive
```

Response (truncated):

```json
{
  "id": "...",
  "driveType": "personal",
  "owner": { "user": { "displayName": "Jane Doe", "email": "[REDACTED_SECRET]" } },
  "quota": { "total": 5368709120, "used": 1024, "remaining": 5368708096, "state": "normal" }
}
```

`driveType` is one of: `personal`, `business`, `documentLibrary`.

### List drives the user can access

```
GET /me/drives
```

### Get specific drive

```
GET /drives/{drive-id}
```

---

## DriveItem (files & folders)

The DriveItem resource represents everything: files, folders, and special folders. Key properties:

- `id` — stable per drive
- `name` — leaf filename
- `size` — bytes
- `folder` (object) — present if folder; contains `childCount`
- `file` (object) — present if file; contains `mimeType`, `hashes`
- `parentReference` — `{driveId, id, path, name}`
- `createdDateTime`, `lastModifiedDateTime`
- `createdBy`, `lastModifiedBy`
- `webUrl` — UI link
- `@microsoft.graph.downloadUrl` — short-lived direct download URL (file only)

### Get item

```
GET /me/drive/root                              # root folder
GET /me/drive/items/{item-id}
GET /me/drive/root:/Documents/file.txt
```

### List children

```
GET /me/drive/items/{folder-id}/children
GET /me/drive/root/children
GET /me/drive/root:/Documents:/children
```

Useful query parameters: `$top`, `$select`, `$expand`, `$orderby`, `$filter`.

### Create folder

```
POST /me/drive/items/{parent-id}/children
Content-Type: application/json

{
  "name": "Reports",
  "folder": {},
  "@microsoft.graph.conflictBehavior": "rename"
}
```

To create under root: `POST /me/drive/root/children`.

### Update metadata (rename, move, set facets)

```
PATCH /me/drive/items/{item-id}
Content-Type: application/json

{
  "name": "renamed.docx",
  "parentReference": { "id": "{new-parent-folder-id}" }
}
```

Including both `name` and `parentReference` does a single rename+move atomically.

### Copy

```
POST /me/drive/items/{item-id}/copy
Content-Type: application/json

{
  "parentReference": { "driveId": "...", "id": "{dest-folder-id}" },
  "name": "copy-of-thing.pdf"
}
```

Copies are asynchronous. Response is `202 Accepted` with a `Location` header pointing at a monitor URL:

```
GET <monitor-url>
→ { "operation": "ItemCopy", "percentageComplete": 100.0, "status": "completed", "resourceId": "{new-item-id}" }
```

### Delete

```
DELETE /me/drive/items/{item-id}
→ 204 No Content
```

Goes to the user's recycle bin. Not recoverable via v1.0 Graph for personal accounts — the user must visit onedrive.live.com → Recycle bin.

### Search

```
GET /me/drive/search(q='budget')
GET /me/drive/root/search(q='*.xlsx')
GET /me/drive/items/{folder-id}/search(q='draft')   # scoped to a subtree
```

Returns DriveItem array. Searches over filename, content (for indexed types), and metadata.

### Recent / shared with me

```
GET /me/drive/recent
GET /me/drive/sharedWithMe
```

`sharedWithMe` returns `remoteItem` references — to do anything with them, follow `remoteItem.parentReference.driveId` + `remoteItem.id`:

```
GET /drives/{remoteItem.parentReference.driveId}/items/{remoteItem.id}
```

### Special folders

```
GET /me/drive/special/documents
GET /me/drive/special/photos
GET /me/drive/special/cameraroll
GET /me/drive/special/approot
GET /me/drive/special/music
```

These are locale-independent. `approot` is unique per app registration — a sandboxed folder.

---

## Upload

### Simple (≤ 4 MiB)

```
PUT /me/drive/root:/path/to/file.ext:/content
Content-Type: <mime-type or application/octet-stream>
Body: <raw bytes>
```

Optional query/header:

- `?@microsoft.graph.conflictBehavior=rename|replace|fail`

### Create-from-children (empty file or with content)

```
POST /me/drive/items/{parent-id}/children
Content-Type: application/json

{
  "name": "new.txt",
  "file": {},
  "@microsoft.graph.conflictBehavior": "rename"
}
```

Then PUT to `/me/drive/items/{new-id}/content` to set bytes.

### Resumable upload (> 4 MiB, recommended for anything > 4 MiB)

**Step 1 — create session:**

```
POST /me/drive/root:/big-video.mp4:/createUploadSession
Content-Type: application/json

{
  "item": {
    "@microsoft.graph.conflictBehavior": "rename",
    "name": "big-video.mp4"
  },
  "deferCommit": false
}
```

Response:

```json
{
  "uploadUrl": "https://api.onedrive.com/rup/...",
  "expirationDateTime": "2026-05-19T11:00:00Z",
  "nextExpectedRanges": ["0-"]
}
```

**Step 2 — upload chunks:**

```
PUT <uploadUrl>
Content-Length: <chunk-size>
Content-Range: bytes {start}-{end}/{total}
Body: <chunk bytes>
```

Rules:

- Chunk size must be a multiple of **320 KiB** (320 × 1024 = 327680 bytes), except the final chunk.
- Recommended chunk size: **5–10 MiB**.
- Send chunks sequentially (parallel is not supported).
- The `uploadUrl` is pre-authenticated — do **not** send the `Authorization` header on the PUTs.

**Step 3 — final chunk** returns the completed DriveItem (HTTP 200 or 201).

If a chunk fails or you need to resume:

```
GET <uploadUrl>
→ { "expirationDateTime": "...", "nextExpectedRanges": ["12345678-"] }
```

Then continue from `nextExpectedRanges[0].start`.

**Cancel an upload session:** `DELETE <uploadUrl>`.

---

## Download

The most reliable pattern is to first GET the item metadata and follow `@microsoft.graph.downloadUrl`:

```
GET /me/drive/items/{id}?$select=id,name,@microsoft.graph.downloadUrl

→ { "name": "...", "@microsoft.graph.downloadUrl": "https://...-1drv.com/..." }
```

That URL is pre-authenticated (no auth header needed), valid for a few minutes. Stream it.

Alternative one-shot endpoint:

```
GET /me/drive/items/{id}/content
→ 302 redirect to a downloadUrl
```

Use `curl -L` to follow the redirect. Some clients have trouble with the redirect — the metadata→downloadUrl pattern is more robust.

---

## Sharing

### Create a sharing link

```
POST /me/drive/items/{id}/createLink
Content-Type: application/json

{
  "type": "view",          // view | edit | embed | block-download
  "scope": "anonymous",    // anonymous | organization | users
  "password": "optional",
  "expirationDateTime": "2026-12-31T00:00:00Z",
  "retainInheritedPermissions": true
}
```

Calling `createLink` multiple times with the same `type`+`scope` returns the **same** link object — sharing links are idempotent per item/type/scope tuple.

### Invite specific users

```
POST /me/drive/items/{id}/invite
Content-Type: application/json

{
  "recipients": [{ "email": "[REDACTED_SECRET]" }, { "email": "[REDACTED_SECRET]" }],
  "roles": ["read"],          // read | write
  "requireSignIn": true,
  "sendInvitation": true,
  "message": "Have a look!",
  "expirationDateTime": "2026-12-31T00:00:00Z",
  "password": "secret"
}
```

Response is an array of created `permission` objects.

### List permissions

```
GET /me/drive/items/{id}/permissions
```

Each permission has:

- `id`
- `roles` — `["read"]` / `["write"]` / `["owner"]`
- `grantedToV2` — `{user, group, application, device}`
- `link` — present for sharing-link permissions: `{type, scope, webUrl, preventsDownload}`
- `invitation` — present for outstanding email invitations
- `expirationDateTime`, `hasPassword`, `inheritedFrom`

### Update a permission

```
PATCH /me/drive/items/{id}/permissions/{perm-id}
Content-Type: application/json

{
  "roles": ["write"],
  "expirationDateTime": "2027-01-01T00:00:00Z"
}
```

### Revoke a permission

```
DELETE /me/drive/items/{id}/permissions/{perm-id}
→ 204
```

Revoking a sharing-link permission invalidates the link for everyone.

### Resolve a sharing URL to a DriveItem

```
GET /shares/{share-id}/driveItem
```

`{share-id}` is `u!` + base64url(URL) **without padding**:

```python
import base64
b = base64.urlsafe_b64encode(url.encode()).rstrip(b'=').decode()
share_id = "u!" + b
```

Or, in bash:

```bash
SHARE_ID="u!$(echo -n "$URL" | base64 -w0 | tr '+/' '-_' | tr -d '=')"
```

You can also access:

- `/shares/{share-id}/root` — the root of the shared content
- `/shares/{share-id}/items/{item-id}` — by ID within the share

This is how `1drv.ms/...` and SharePoint share URLs are resolved.

---

## Versions

```
GET   /me/drive/items/{id}/versions
GET   /me/drive/items/{id}/versions/{version-id}              # metadata
GET   /me/drive/items/{id}/versions/{version-id}/content      # download a previous version
POST  /me/drive/items/{id}/versions/{version-id}/restoreVersion
```

Versions are kept for 30 days (personal) / per retention policy (business).

---

## Thumbnails

```
GET /me/drive/items/{id}/thumbnails
GET /me/drive/items/{id}/thumbnails/0/{size}
GET /me/drive/items/{id}/thumbnails/0/{size}/content   # 302 → image bytes
```

Sizes: `small` (≤96 px), `medium` (≤176 px), `large` (≤800 px), or a custom dimension like `c1000x1000` / `c500x500_crop`.

---

## Preview

```
POST /me/drive/items/{id}/preview
Content-Type: application/json
{ "page": "1", "zoom": 90 }

→ { "getUrl": "...", "postUrl": "...", "postParameters": "..." }
```

`getUrl` is an iframe-ready embed URL.

---

## Delta (change feed)

```
GET /me/drive/root/delta                          # initial snapshot
GET /me/drive/root/delta?[REDACTED_SECRET]       # incremental
GET /me/drive/items/{folder-id}/delta             # scoped to a subtree
```

Responses:

- `value[]` — list of changed DriveItems. A `deleted` facet indicates removal.
- `@odata.nextLink` — more pages of the current sync.
- `@odata.deltaLink` — when the sync is complete; contains the next sync token.

Save the `token=` parameter from `@odata.deltaLink` and pass it back on the next call to fetch only changes since then.

---

## Activities

```
GET /me/drive/activities                          # tenant-wide (business)
GET /me/drive/items/{id}/activities               # per-item
```

Returns an `itemActivity` array with `action.create/edit/delete/rename/move/share/...` facets.

---

## Workbook (Excel) — quick pointers

If the DriveItem is an `.xlsx`:

```
GET   /me/drive/items/{id}/workbook/worksheets
GET   /me/drive/items/{id}/workbook/worksheets/Sheet1/range(address='A1:D10')
POST  /me/drive/items/{id}/workbook/createSession           # for transactional edits
PATCH /me/drive/items/{id}/workbook/worksheets/Sheet1/range(address='A1:D10')/values
```

Workbook calls accept a `workbook-session-id` header for ACID-like transactional editing.

---

## OData query parameters

| Param | Example | Notes |
|-------|---------|-------|
| `$select` | `?$select=id,name,size,folder` | Project specific fields — reduces payload size |
| `$expand` | `?$expand=children($top=10)` | Inline related resources |
| `$filter` | `?$filter=file ne null` | OData filter expressions |
| `$orderby` | `?$orderby=lastModifiedDateTime desc` | Sort |
| `$top` | `?$top=200` | Page size — max 200 for `children` |
| `$skip` | `?$skip=100` | Offset (rarely needed — prefer `@odata.nextLink`) |
| `$count` | `?$count=true` | Returns total alongside results |
| `$search` | `?$search="budget"` | Full-text — only on some endpoints |

URL encoding tip: `$` does not need to be encoded, but `,` inside `$select` does not need it either. Quotes do (`%22`).

### Pagination

```
GET /me/drive/root/children?$top=100
→ {
    "value": [ ... 100 items ... ],
    "@odata.nextLink": "https://graph.microsoft.com/v1.0/me/drive/root/children?$top=100&$skiptoken=..."
  }
```

Follow `@odata.nextLink` blindly — it already encodes everything needed.

---

## Conflict behavior

When creating/uploading items, set `@microsoft.graph.conflictBehavior`:

- `fail` (default) — 409 if an item with that name exists.
- `replace` — overwrite existing item content (same ID).
- `rename` — append ` (1)`, ` (2)`, … to the new item's name.

Pass it in:

- The JSON body for `POST .../children`, `POST .../copy`, `POST .../createUploadSession`.
- The query string for `PUT .../content?@microsoft.graph.conflictBehavior=replace`.

---

## Throttling

OneDrive / Graph applies per-app and per-user throttling. When throttled:

```
HTTP/1.1 429 Too Many Requests
Retry-After: 30
```

Pause for `Retry-After` seconds (default several hundred ms if missing) and retry. Backoff exponentially on repeated 429s.

Use `$select` to minimize response sizes and batch unrelated calls with the [JSON batch endpoint](https://learn.microsoft.com/en-us/graph/json-batching):

```
POST https://graph.microsoft.com/v1.0/$batch
Content-Type: application/json

{
  "requests": [
    { "id": "1", "method": "GET", "url": "/me/drive/root/children?$top=50" },
    { "id": "2", "method": "GET", "url": "/me/drive/recent?$top=10" }
  ]
}
```

Up to 20 requests per batch.

---

## Errors

Standard shape:

```json
{
  "error": {
    "code": "itemNotFound",
    "message": "The resource could not be found.",
    "innerError": {
      "date": "2026-05-19T10:11:12",
      "request-id": "abcd-1234",
      "client-request-id": "..."
    }
  }
}
```

Common codes: `accessDenied`, `activityLimitReached`, `generalException`, `invalidRequest`, `invalidRange`, `itemNotFound`, `malwareDetected`, `nameAlreadyExists`, `notAllowed`, `notSupported`, `resourceModified`, `resyncRequired`, `serviceNotAvailable`, `quotaLimitReached`, `unauthenticated`.

For `resyncRequired` (from `/delta`): drop your delta token and start a fresh full sync.


path: references/permissions.md
content: # OAuth Scopes & Permissions

Microsoft Graph distinguishes two permission models:

| Type | Used by | Token issued for |
|------|---------|------------------|
| **Delegated** | Apps acting on behalf of a signed-in user | A specific user (`/me`, `/users/{me-id}`) |
| **Application** | Apps acting as themselves (daemons, services) | The application — must call `/users/{user-id}` not `/me` |

The setup script in this skill uses **delegated** permissions with the OAuth2 authorization code flow.

## Scopes used by this skill

| Scope | Type | Purpose |
|-------|------|---------|
| `https://graph.microsoft.com/Files.ReadWrite.All` | Delegated | Read & write all files the signed-in user can access |
| `https://graph.microsoft.com/Sites.ReadWrite.All` | Delegated | SharePoint & OneDrive for Business document libraries |
| `https://graph.microsoft.com/User.Read` | Delegated | Sign in & read basic profile |
| `offline_access` | Delegated | Issue refresh tokens (no `https://...` prefix) |

> `offline_access` is a special OpenID Connect scope. It must be requested at sign-in but is **not** added on the Azure app registration's "API permissions" page.

## All file-related delegated scopes

| Scope | What it grants | When to use |
|-------|----------------|-------------|
| `Files.Read` | Read user's own files | Read-only personal apps |
| `Files.ReadWrite` | Read/write user's own files | Write to personal drive only |
| `Files.Read.All` | Read any file the user can access (incl. shared) | Read-only across shared/business |
| `Files.ReadWrite.All` | Read/write any file the user can access | Full control (this skill's default) |
| `Files.Read.Selected` | App can only access files the user picked via picker | Sandboxed integrations |
| `Files.ReadWrite.Selected` | Same, but writable | Sandboxed integrations |
| `Files.ReadWrite.AppFolder` | Read/write within the app's dedicated folder | Apps using `special/approot` |

For SharePoint / business doc libraries:

| Scope | Purpose |
|-------|---------|
| `Sites.Read.All` | Read items in all SharePoint sites the user can access |
| `Sites.ReadWrite.All` | Read/write items in all sites |
| `Sites.Manage.All` | Create/edit lists & list items |
| `Sites.FullControl.All` | Full control (admin-only consent) |
| `Sites.Selected` | Restrict app to specific sites granted by admin |

## Application (daemon) scopes

For service-to-service automation (no signed-in user), use the client-credentials flow with these:

| Scope | Notes |
|-------|-------|
| `Files.Read.All` | Application — read across the tenant |
| `Files.ReadWrite.All` | Application — read/write across the tenant |
| `Sites.Selected` | Restricted to sites granted by an admin |

App-only tokens **cannot** call `/me/...`. You must address by user/site/drive ID:

```
GET /users/{user-upn-or-id}/drive/root/children
GET /sites/{site-id}/drive/root/children
```

To get a token using client credentials:

```bash
curl -s -X POST "https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "client_id=$CLIENT_ID" \
  --data-urlencode "client_secret=$CLIENT_SECRET" \
  --data-urlencode "scope=https://graph.microsoft.com/.default" \
  --data-urlencode "grant_type=client_credentials"
```

Note the special scope `https://graph.microsoft.com/.default` — it requests all permissions an admin has already consented to. No refresh token is issued; just get a new one when needed (also valid ~60 minutes).

Then put it into the skill:

```bash
./scripts/onedrive-token.sh set "$TOKEN"
export ONEDRIVE_DRIVE_PREFIX="/users/[REDACTED_SECRET]/drive"
./scripts/onedrive-files.sh list
```

## Consent

**Personal Microsoft accounts** can self-consent to any delegated scope.

**Work / school (Microsoft 365)** accounts:

- Some scopes (e.g. `User.Read`, `Files.Read`) can be user-consented.
- Most `*.All` and all `Sites.*.All` scopes typically require **admin consent**.
- Admins grant tenant-wide consent at:
  - Azure Portal → Microsoft Entra ID → App registrations → {your app} → API permissions → **Grant admin consent for {tenant}**.
- Or via the URL:

  ```
  https://login.microsoftonline.com/{tenant}/adminconsent
    ?client_id={CLIENT_ID}
    &redirect_uri=http://localhost
  ```

## Choosing the right tenant string

The auth/token URLs use `/{tenant}/` segment:

| Value | Audience |
|-------|----------|
| `common` | Any Microsoft account (personal + work/school, any tenant) — default |
| `consumers` | Personal Microsoft accounts only |
| `organizations` | Work/school accounts in any tenant |
| `{tenant-id}` or `{domain}` | Specific tenant only |

The setup script defaults to `common`. Override with `ONEDRIVE_TENANT=...` when running it.

## Minimum scopes per operation

| You want to | Minimum delegated scope |
|-------------|--------------------------|
| List your own files | `Files.Read` |
| Upload/download your own files | `Files.ReadWrite` |
| Access files shared with you | `Files.Read.All` |
| Modify files shared with you | `Files.ReadWrite.All` |
| Read SharePoint sites | `Sites.Read.All` |
| Write to SharePoint sites | `Sites.ReadWrite.All` |
| Stay signed in (refresh tokens) | `offline_access` |
| Show user profile | `User.Read` |

This skill requests `Files.ReadWrite.All Sites.ReadWrite.All User.Read offline_access` as a sane default. Trim down if you have a strict least-privilege requirement.


path: references/setup.md
content: # OneDrive Manual Setup Guide

Use this guide if you don't have the Azure CLI (`az`) or want to do everything through the Azure Portal UI.

## Prerequisites

- A Microsoft account (personal `outlook.com`/`hotmail.com`/`live.com` **or** Microsoft 365 work/school).
- Access to [Azure Portal](https://portal.azure.com).
- `jq` and `curl` installed locally.

> Personal accounts can self-consent to any scope. Work/school accounts may require an administrator to grant tenant-wide consent for `*.All` scopes.

## Step 1 — Register an Azure AD application

1. Go to https://portal.azure.com → **Microsoft Entra ID** → **App registrations**.
2. Click **+ New registration**.
3. Fill in:
   - **Name:** `Clawdbot-OneDrive` (or anything you like).
   - **Supported account types:** _"Accounts in any organizational directory and personal Microsoft accounts (Any Microsoft Entra directory — Multitenant — and personal Microsoft accounts)"_.
   - **Redirect URI:** Platform = **Web**, URI = `http://localhost`.
4. Click **Register**.

## Step 2 — Get the client ID and secret

On the app's overview page:

1. Copy **Application (client) ID** → this is your `CLIENT_ID`.
2. (Optional) Copy **Directory (tenant) ID** if you want to lock the app to one tenant.
3. Left menu → **Certificates & secrets** → **+ New client secret**.
4. Description: `onedrive-cli`. Expiration: choose up to 24 months.
5. **Copy the `Value` immediately** — you can't see it again. This is your `CLIENT_SECRET`.

## Step 3 — Configure API permissions

1. Left menu → **API permissions** → **+ Add a permission**.
2. Choose **Microsoft Graph** → **Delegated permissions**.
3. Add these scopes:
   - `Files.ReadWrite.All` — read & write all files the user can access.
   - `Sites.ReadWrite.All` — required for SharePoint / OneDrive for Business document libraries.
   - `User.Read` — needed for sign-in (basic profile).
4. Click **Add permissions**.

`offline_access` is requested at sign-in time and does not need to be added here.

If you are on a work/school tenant where admin consent is required, click **Grant admin consent for {tenant}** (only an admin can do this).

## Step 4 — Save your config

Create the directory and config file locally:

```bash
mkdir -p ~/.onedrive-mcp && chmod 700 ~/.onedrive-mcp

cat > ~/.onedrive-mcp/config.json <<'EOF'
{
  "client_id":     "YOUR_CLIENT_ID",
  "client_secret": "YOUR_CLIENT_SECRET",
  "tenant":        "common",
  "redirect_uri":  "http://localhost",
  "scopes":        "https://graph.microsoft.com/Files.ReadWrite.All https://graph.microsoft.com/Sites.ReadWrite.All https://graph.microsoft.com/User.Read offline_access"
}
EOF

chmod 600 ~/.onedrive-mcp/config.json
```

For a single-tenant business app, replace `"tenant": "common"` with `"tenant": "YOUR_TENANT_ID"`.

## Step 5 — Authorize (get an authorization code)

Build this URL (replace `YOUR_CLIENT_ID`):

```
https://login.microsoftonline.com/common/oauth2/v2.0/authorize
  ?client_id=YOUR_CLIENT_ID
  &response_type=code
  &redirect_uri=http://localhost
  &response_mode=query
  &scope=https://graph.microsoft.com/Files.ReadWrite.All%20https://graph.microsoft.com/Sites.ReadWrite.All%20https://graph.microsoft.com/User.Read%20offline_access
```

(Put it on one line, no spaces. Replace newlines with nothing.)

1. Open the URL in a browser.
2. Sign in with the Microsoft account that owns the OneDrive you want to use.
3. Approve the requested permissions.
4. You'll be redirected to `http://localhost?code=XXXXX...` (the page won't load — that's fine).
5. Copy the value of `code=` (everything between `code=` and either `&` or end of URL).

## Step 6 — Exchange the code for tokens

```bash
CLIENT_ID="..."
CLIENT_SECRET="..."
CODE="the-code-from-step-5"
SCOPES="https://graph.microsoft.com/Files.ReadWrite.All https://graph.microsoft.com/Sites.ReadWrite.All https://graph.microsoft.com/User.Read offline_access"

curl -s -X POST "https://login.microsoftonline.com/common/oauth2/v2.0/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "client_id=$CLIENT_ID" \
  --data-urlencode "client_secret=$CLIENT_SECRET" \
  --data-urlencode "code=$CODE" \
  --data-urlencode "redirect_uri=http://localhost" \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "scope=$SCOPES" \
  | tee ~/.onedrive-mcp/credentials.json | jq

chmod 600 ~/.onedrive-mcp/credentials.json
```

You should see a JSON response with `access_token`, `refresh_token`, `expires_in`, etc.

## Step 7 — Verify

```bash
TOKEN=$(jq -r '.access_token' ~/.onedrive-mcp/credentials.json)
curl -s "https://graph.microsoft.com/v1.0/me/drive" \
  -H "Authorization: Bearer $TOKEN" \
  | jq '{owner: .owner.user.displayName, total: .quota.total, used: .quota.used}'
```

If you see your name and quota numbers, you're set. From here use the scripts:

```bash
./scripts/onedrive-files.sh list
./scripts/onedrive-files.sh quota
./scripts/onedrive-token.sh info
```

## Refreshing tokens

Access tokens expire after ~60 minutes. As long as `refresh_token` is present in `credentials.json`, refresh with:

```bash
./scripts/onedrive-token.sh refresh
```

Or directly:

```bash
RT=$(jq -r '.refresh_token' ~/.onedrive-mcp/credentials.json)
curl -s -X POST "https://login.microsoftonline.com/common/oauth2/v2.0/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "client_id=$CLIENT_ID" \
  --data-urlencode "client_secret=$CLIENT_SECRET" \
  --data-urlencode "refresh_token=$RT" \
  --data-urlencode "grant_type=refresh_token" \
  --data-urlencode "scope=$SCOPES" \
  > ~/.onedrive-mcp/credentials.json
```

A new `refresh_token` is rotated on each call — always overwrite the file.

## Using an existing access token (BYO token)

If you already have a Microsoft Graph access token from somewhere else (Postman, MSAL, another app, service principal, etc.):

```bash
# Option 1 — env var (one-off)
export ONEDRIVE_ACCESS_TOKEN="eyJ0eXAi..."
./scripts/onedrive-files.sh list

# Option 2 — persisted
./scripts/onedrive-token.sh set "eyJ0eXAi..." "optional-refresh-token"
```

The token must have at least `Files.Read` (or `Files.ReadWrite` for writes) and `User.Read` to call the endpoints used here.

## Troubleshooting

**AADSTS50011: Reply URL does not match**
The redirect URI configured on the app must match the `redirect_uri` parameter exactly, including trailing slash. Use `http://localhost` (no trailing slash) consistently.

**AADSTS65001: User or administrator has not consented**
For work/school tenants, an admin must click _Grant admin consent_ on the API permissions page. Alternatively use a personal account.

**AADSTS70011: Invalid scope**
- Scopes must be space-separated, not comma-separated.
- Use the full URI form (`https://graph.microsoft.com/Files.ReadWrite.All`) — short forms only work for some flows.
- `offline_access` is the only scope that does NOT take the Graph URI prefix.

**Token works for `/me/drive` but not `/sites/...`**
You're missing `Sites.Read.All` or `Sites.ReadWrite.All`. Reconsent.

**`invalid_grant: AADSTS70008: refresh token has expired`**
Refresh tokens last 90 days (personal) or until the tenant policy revokes them. Re-run the full authorize flow (Step 5).


path: scripts/onedrive-bootstrap.sh
content: #!/usr/bin/env bash
# onedrive-bootstrap.sh — Headless OneDrive (Microsoft Graph) provisioning.
#
# Templated install: the deploy system substitutes the __PLACEHOLDERS__ below
# with real values before running. No browser, no Azure CLI needed.
#
# Drops:
#   ~/.onedrive-mcp/config.json       client_id, client_secret
#   ~/.onedrive-mcp/credentials.json  access_token, refresh_token, expires_in
# Then refreshes the token at Microsoft Entra and probes Microsoft Graph
# (`/me/drive`) to confirm the install.

set -euo pipefail
export DEBIAN_FRONTEND=noninteractive

export HOME="${HOME:-/root}"
export PATH="$(npm config get prefix 2>/dev/null)/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
hash -r

# === Substituted at deploy time =====================================
CLIENT_ID='__CLIENT_ID__'
CLIENT_SECRET='__CLIENT_SECRET__'
ACCESS_TOKEN='__ACCESS_TOKEN__'
REFRESH_TOKEN='__REFRESH_TOKEN__'
TENANT='__TENANT__'                       # e.g. 'common' or a tenant GUID
SCOPE='__ONEDRIVE_SCOPES__'               # space-separated, full URI form
# ====================================================================

# Fallbacks if the deploy system left something unsubstituted
[ "$TENANT" = "__TENANT__" ] && TENANT="common"
[ "$SCOPE"  = "__ONEDRIVE_SCOPES__" ] && SCOPE="https://graph.microsoft.com/Files.ReadWrite.All https://graph.microsoft.com/Sites.ReadWrite.All https://graph.microsoft.com/User.Read offline_access"

echo "===================================================="
echo "[onedrive/setup] starting"
echo "===================================================="

if ! command -v jq >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1; then
  echo "[onedrive/setup] installing jq + curl (missing)..."
  apt-get update -qq
  apt-get install -y -qq jq curl ca-certificates
else
  echo "[onedrive/setup] jq + curl already present, skipping apt install"
fi

echo "[onedrive/setup] stopping openclaw (if running)..."
killall openclaw 2>/dev/null || true

echo "[onedrive/setup] writing config + credentials to ~/.onedrive-mcp/..."
mkdir -p "$HOME/.onedrive-mcp"
chmod 700 "$HOME/.onedrive-mcp"

cat > "$HOME/.onedrive-mcp/config.json" <<EOF
{
  "client_id": "$CLIENT_ID",
  "client_secret": "$CLIENT_SECRET",
  "tenant": "$TENANT",
  "redirect_uri": "http://localhost",
  "scopes": "$SCOPE"
}
EOF
chmod 600 "$HOME/.onedrive-mcp/config.json"

cat > "$HOME/.onedrive-mcp/credentials.json" <<EOF
{
  "token_type": "Bearer",
  "access_token": "$ACCESS_TOKEN",
  "refresh_token": "$REFRESH_TOKEN",
  "expires_in": 3600
}
EOF
chmod 600 "$HOME/.onedrive-mcp/credentials.json"

CREDS="$HOME/.onedrive-mcp/credentials.json"
RT_KEEP="$(jq -r '.refresh_token // empty' "$CREDS" | tr -d '\r\n\t ')"

if [ -z "$RT_KEEP" ] || [ "$RT_KEEP" = "null" ]; then
  echo "[onedrive/setup] ERROR: missing refresh_token in credentials file." >&2
  exit 1
fi

TOKEN_URL="https://login.microsoftonline.com/${TENANT}/oauth2/v2.0/token"

echo "[onedrive/setup] refreshing access_token at Microsoft Entra (tenant=$TENANT)..."
TOKEN_JSON="$(curl --http1.1 -sS -X POST "$TOKEN_URL" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "client_id=$CLIENT_ID" \
  --data-urlencode "client_secret=$CLIENT_SECRET" \
  --data-urlencode "grant_type=refresh_token" \
  --data-urlencode "refresh_token=$RT_KEEP" \
  --data-urlencode "scope=$SCOPE")"

if ! echo "$TOKEN_JSON" | jq -e '.access_token' >/dev/null 2>&1; then
  echo "[onedrive/setup] ERROR: Microsoft token refresh failed: $(echo "$TOKEN_JSON" | head -c 3000 | tr -d '\r\n')" >&2
  exit 1
fi
echo "[onedrive/setup] token refresh OK"

NOW="$(date +%s)"
# Persist new tokens (Microsoft rotates the refresh_token); annotate with expiry
echo "$TOKEN_JSON" \
  | jq --arg rt "$RT_KEEP" --argjson now "$NOW" \
       '. + {
          refresh_token: (.refresh_token // $rt),
          token_type:    (.token_type // "Bearer"),
          acquired_at:   $now,
          expires_at:    ($now + (.expires_in // 3600))
        }' \
  > "$CREDS"
chmod 600 "$CREDS"

AT="$(jq -r '.access_token' "$CREDS" | tr -d '\r\n\t ')"

if [ -z "$AT" ] || [ "$AT" = "null" ]; then
  echo "[onedrive/setup] ERROR: no access_token after refresh." >&2
  exit 1
fi

echo "[onedrive/setup] probing Microsoft Graph /me/drive..."

HTTP="$(curl --http1.1 -sS \
  -D /tmp/onedrive-probe.headers \
  -o /tmp/onedrive-probe.body \
  -w "%{http_code}" \
  -H "Authorization: Bearer ${AT}" \
  -H "Accept: application/json" \
  'https://graph.microsoft.com/v1.0/me/drive')"

if [ "$HTTP" != "200" ]; then
  echo "[onedrive/setup] ERROR: Graph drive probe failed HTTP $HTTP" >&2
  echo "---- headers ----" >&2
  cat /tmp/onedrive-probe.headers >&2
  echo "---- body ----" >&2
  head -c 3000 /tmp/onedrive-probe.body | tr -d '\r' >&2
  echo >&2
  exit 1
fi

echo "[onedrive/setup] Graph drive response:"
cat /tmp/onedrive-probe.body \
  | jq '{
      owner: (.owner.user.displayName // .owner.user.email // "?"),
      driveType,
      total: .quota.total,
      used:  .quota.used,
      remaining: .quota.remaining
    }'

# Quick listing probe to confirm read scope
echo "[onedrive/setup] probing Microsoft Graph /me/drive/root/children (top=1)..."
HTTP2="$(curl --http1.1 -sS \
  -o /tmp/onedrive-children.body \
  -w "%{http_code}" \
  -H "Authorization: Bearer ${AT}" \
  -H "Accept: application/json" \
  'https://graph.microsoft.com/v1.0/me/drive/root/children?$top=1&$select=id,name,folder,file')"
if [ "$HTTP2" = "200" ]; then
  COUNT="$(jq '.value | length' /tmp/onedrive-children.body 2>/dev/null || echo 0)"
  echo "[onedrive/setup] root listing OK (sample items: $COUNT)"
else
  echo "[onedrive/setup] WARN: root listing returned HTTP $HTTP2 (not fatal)" >&2
fi

chmod 711 /root

echo "[onedrive/setup] updating ~/.bashrc env exports..."
sed -i '/ONEDRIVE_MCP/d' ~/.bashrc 2>/dev/null || true
cat <<'EOF' >> ~/.bashrc
export ONEDRIVE_MCP_CONFIG_DIR="/root/.onedrive-mcp"
EOF

echo "===================================================="
echo "[onedrive/setup] done — OneDrive Graph access OK (HTTP 200)"
echo "===================================================="


path: scripts/onedrive-files.sh
content: #!/usr/bin/env bash
# onedrive-files.sh — OneDrive file & folder operations via Microsoft Graph
#
# Usage: onedrive-files.sh <command> [args]
# Run without args to print full help.

set -euo pipefail

CONFIG_DIR="${ONEDRIVE_MCP_CONFIG_DIR:-$HOME/.onedrive-mcp}"
CREDS_FILE="$CONFIG_DIR/credentials.json"
GRAPH="${ONEDRIVE_GRAPH_BASE:-https://graph.microsoft.com/v1.0}"
DRIVE_PREFIX="${ONEDRIVE_DRIVE_PREFIX:-/me/drive}"  # override to e.g. /drives/{id} or /sites/{id}/drive

get_token() {
    if [ -n "${ONEDRIVE_ACCESS_TOKEN:-}" ]; then echo "$ONEDRIVE_ACCESS_TOKEN"; return; fi
    [ -f "$CREDS_FILE" ] || { echo "Error: no token. Run onedrive-setup.sh or set ONEDRIVE_ACCESS_TOKEN" >&2; exit 1; }
    local t; t=$(jq -r '.access_token // empty' "$CREDS_FILE")
    [ -n "$t" ] || { echo "Error: empty access_token in $CREDS_FILE" >&2; exit 1; }
    echo "$t"
}

# URL-encode a string segment (paths)
url_encode_path() {
    # Encode each path segment but keep '/' separators
    python3 -c '
import sys, urllib.parse
p = sys.argv[1].strip("/")
print("/".join(urllib.parse.quote(seg, safe="") for seg in p.split("/")))
' "$1" 2>/dev/null || echo "$1"
}

# Build a Graph URL for an item by path or by ID.
# Input: either "01ABC..." (id) or "Documents/Reports/q4.xlsx" (path)
# Optional suffix: ":/content", "/children", "/createUploadSession", etc.
item_url() {
    local target="$1" suffix="${2:-}"
    if [[ "$target" =~ ^[A-Za-z0-9!_-]{8,}$ ]] && [[ "$target" != */* ]]; then
        # Treat as item ID
        echo "$GRAPH$DRIVE_PREFIX/items/$target$suffix"
    else
        # Treat as path
        local enc
        enc=$(url_encode_path "$target")
        if [ -z "$suffix" ]; then
            echo "$GRAPH$DRIVE_PREFIX/root:/$enc"
        else
            echo "$GRAPH$DRIVE_PREFIX/root:/$enc:$suffix"
        fi
    fi
}

# Resolve a path-or-id input to a full item ID
resolve_id() {
    local target="$1"
    if [[ "$target" =~ ^[A-Za-z0-9!_-]{8,}$ ]] && [[ "$target" != */* ]]; then
        echo "$target"; return
    fi
    local token; [REDACTED_SECRET])
    local url; url=$(item_url "$target")
    curl -fsS -H "Authorization: Bearer $token" "$url?\$select=id" | jq -r '.id'
}

graph_get() {
    local path="$1"
    local token; [REDACTED_SECRET])
    curl -fsS "$GRAPH$path" -H "Authorization: Bearer $token"
}

# ===== Commands =====

cmd_list() {
    # list [path]
    local path="${1:-}"
    local url
    if [ -z "$path" ]; then
        url="$GRAPH$DRIVE_PREFIX/root/children"
    else
        url=$(item_url "$path" "/children")
    fi
    url="$url?\$top=200&\$select=id,name,size,folder,file,lastModifiedDateTime,webUrl,parentReference"
    local token; [REDACTED_SECRET])
    curl -fsS "$url" -H "Authorization: Bearer $token" \
      | jq '.value[] | {
            kind: (if .folder then "dir" else "file" end),
            name,
            size,
            modified: .lastModifiedDateTime,
            id,
            mime: .file.mimeType,
            children: .folder.childCount
        }'
}

cmd_list_id() {
    local id="${1:?usage: list-id <item-id>}"
    local token; [REDACTED_SECRET])
    curl -fsS "$GRAPH$DRIVE_PREFIX/items/$id/children?\$top=200" \
        -H "Authorization: Bearer $token" \
      | jq '.value[] | {kind:(if .folder then "dir" else "file" end), name, size, id}'
}

cmd_list_special() {
    local name="${1:?usage: list-special <documents|photos|cameraroll|approot|music>}"
    local token; [REDACTED_SECRET])
    curl -fsS "$GRAPH$DRIVE_PREFIX/special/$name/children?\$top=200" \
        -H "Authorization: Bearer $token" \
      | jq '.value[] | {kind:(if .folder then "dir" else "file" end), name, size, id}'
}

cmd_recent() {
    local top="${1:-25}"
    local token; [REDACTED_SECRET])
    curl -fsS "$GRAPH$DRIVE_PREFIX/recent?\$top=$top" \
        -H "Authorization: Bearer $token" \
      | jq '.value[] | {name, size, modified: .lastModifiedDateTime, id, parent: .parentReference.path}'
}

cmd_shared() {
    local top="${1:-25}"
    local token; [REDACTED_SECRET])
    curl -fsS "$GRAPH$DRIVE_PREFIX/sharedWithMe?\$top=$top" \
        -H "Authorization: Bearer $token" \
      | jq '.value[] | {name, size, owner: .remoteItem.shared.owner.user.displayName, id: .remoteItem.id, driveId: .remoteItem.parentReference.driveId, url: .webUrl}'
}

cmd_info() {
    local target="${1:?usage: info <path-or-id>}"
    local token; [REDACTED_SECRET])
    local url; url=$(item_url "$target")
    curl -fsS "$url" -H "Authorization: Bearer $token" | jq
}

cmd_stat() {
    local target="${1:?usage: stat <path-or-id>}"
    local token; [REDACTED_SECRET])
    local url; url=$(item_url "$target")
    curl -fsS "$url?\$select=id,name,size,file,folder,createdDateTime,lastModifiedDateTime" \
        -H "Authorization: Bearer $token" \
      | jq '{name, size, mime: .file.mimeType, created: .createdDateTime, modified: .lastModifiedDateTime, id}'
}

cmd_thumbnail() {
    # thumbnail <id-or-path> [small|medium|large]
    local target="${1:?usage: thumbnail <path-or-id> [size]}"
    local size="${2:-medium}"
    local token; [REDACTED_SECRET])
    local url; url=$(item_url "$target" "/thumbnails")
    curl -fsS "$url" -H "Authorization: Bearer $token" \
      | jq --arg size "$size" '.value[0][$size] // .value[0]'
}

cmd_preview() {
    local target="${1:?usage: preview <path-or-id>}"
    local token; [REDACTED_SECRET])
    local url; url=$(item_url "$target" "/preview")
    curl -fsS -X POST "$url" -H "Authorization: Bearer $token" -H "Content-Length: 0" | jq
}

cmd_versions() {
    local target="${1:?usage: versions <path-or-id>}"
    local token; [REDACTED_SECRET])
    local url; url=$(item_url "$target" "/versions")
    curl -fsS "$url" -H "Authorization: Bearer $token" \
      | jq '.value[] | {id, size, modified: .lastModifiedDateTime, by: .lastModifiedBy.user.displayName}'
}

cmd_mkdir() {
    # mkdir <path>   — creates all missing parents
    local path="${1:?usage: mkdir <path>}"
    local token; [REDACTED_SECRET])
    # Split into segments and create each one if missing
    local IFS='/' parts; read -r -a parts <<< "$path"
    local parent_id=""
    for seg in "${parts[@]}"; do
        [ -z "$seg" ] && continue
        # Look up under parent
        local list_url
        if [ -z "$parent_id" ]; then
            list_url="$GRAPH$DRIVE_PREFIX/root/children"
        else
            list_url="$GRAPH$DRIVE_PREFIX/items/$parent_id/children"
        fi
        local existing
        existing=$(curl -fsS "$list_url?\$select=id,name,folder&\$top=200" \
            -H "Authorization: Bearer $token" \
            | jq -r --arg n "$seg" '.value[] | select(.name == $n and .folder != null) | .id')
        if [ -n "$existing" ]; then
            parent_id="$existing"
            continue
        fi
        # Create
        local create_url
        if [ -z "$parent_id" ]; then
            create_url="$GRAPH$DRIVE_PREFIX/root/children"
        else
            create_url="$GRAPH$DRIVE_PREFIX/items/$parent_id/children"
        fi
        local body new_id
        body=$(jq -n --arg n "$seg" '{name:$n, folder:{}, "@microsoft.graph.conflictBehavior":"fail"}')
        new_id=$(curl -fsS -X POST "$create_url" \
            -H "Authorization: Bearer $token" \
            -H "Content-Type: application/json" \
            -d "$body" | jq -r '.id')
        parent_id="$new_id"
    done
    echo "{\"status\":\"created\",\"path\":\"$path\",\"id\":\"$parent_id\"}"
}

cmd_upload() {
    # upload <local-file> <remote-path>
    local local_file="${1:?usage: upload <local-file> <remote-path>}"
    local remote_path="${2:?usage: upload <local-file> <remote-path>}"
    [ -f "$local_file" ] || { echo "Error: file not found: $local_file" >&2; exit 1; }
    local token; [REDACTED_SECRET])
    local size
    size=$(stat -c%s "$local_file" 2>/dev/null || stat -f%z "$local_file")
    local enc; enc=$(url_encode_path "$remote_path")

    if [ "$size" -le 4000000 ]; then
        # Simple PUT
        local url="$GRAPH$DRIVE_PREFIX/root:/$enc:/content?@microsoft.graph.conflictBehavior=replace"
        curl -fsS -X PUT "$url" \
            -H "Authorization: Bearer $token" \
            -H "Content-Type: application/octet-stream" \
            --data-binary "@$local_file" \
          | jq '{status:"uploaded", name, size, id, url: .webUrl}'
    else
        # Resumable
        local session_url="$GRAPH$DRIVE_PREFIX/root:/$enc:/createUploadSession"
        local session
        session=$(curl -fsS -X POST "$session_url" \
            -H "Authorization: Bearer $token" \
            -H "Content-Type: application/json" \
            -d '{"item":{"@microsoft.graph.conflictBehavior":"replace"}}')
        local upload_url
        upload_url=$(echo "$session" | jq -r '.uploadUrl')
        if [ -z "$upload_url" ] || [ "$upload_url" = "null" ]; then
            echo "$session" | jq '.error // .' >&2; exit 1
        fi
        # Stream chunks of 10 MiB (10485760 = 32 * 327680, multiple of 320 KiB)
        local chunk=10485760
        local offset=0 last result
        while [ "$offset" -lt "$size" ]; do
            local end=$((offset + chunk - 1))
            [ "$end" -ge "$size" ] && end=$((size - 1))
            local len=$((end - offset + 1))
            result=$(dd if="$local_file" bs=1 skip="$offset" count="$len" 2>/dev/null \
                | curl -fsS -X PUT "$upload_url" \
                    -H "Content-Length: $len" \
                    -H "Content-Range: bytes $offset-$end/$size" \
                    --data-binary @-)
            offset=$((end + 1))
            echo "$result" | jq -c '{nextExpectedRanges, id, name, size, status:"chunk"}' >&2 || true
            last="$result"
        done
        echo "$last" | jq '{status:"uploaded", name, size, id, url: .webUrl}'
    fi
}

cmd_upload_stream() {
    # upload-stream <remote-path>   — reads stdin
    local remote_path="${1:?usage: upload-stream <remote-path>}"
    local token; [REDACTED_SECRET])
    local enc; enc=$(url_encode_path "$remote_path")
    local url="$GRAPH$DRIVE_PREFIX/root:/$enc:/content?@microsoft.graph.conflictBehavior=replace"
    curl -fsS -X PUT "$url" \
        -H "Authorization: Bearer $token" \
        -H "Content-Type: application/octet-stream" \
        --data-binary @- \
      | jq '{status:"uploaded", name, size, id, url: .webUrl}'
}

cmd_download() {
    # download <remote> [local]
    local remote="${1:?usage: download <remote-path-or-id> [local-path]}"
    local local_path="${2:-}"
    local token; [REDACTED_SECRET])
    # Get download URL from item metadata
    local info_url; info_url=$(item_url "$remote")
    local info
    info=$(curl -fsS "$info_url?\$select=id,name,@microsoft.graph.downloadUrl" \
        -H "Authorization: Bearer $token")
    local name dl
    name=$(echo "$info" | jq -r '.name')
    dl=$(echo "$info" | jq -r '."@microsoft.graph.downloadUrl"')
    [ -z "$local_path" ] && local_path="$name"
    # downloadUrl is pre-authenticated — no auth header
    curl -fsS -L "$dl" -o "$local_path"
    local size
    size=$(stat -c%s "$local_path" 2>/dev/null || stat -f%z "$local_path")
    echo "{\"status\":\"downloaded\",\"file\":\"$local_path\",\"size\":$size}"
}

cmd_cat() {
    local remote="${1:?usage: cat <path-or-id>}"
    local token; [REDACTED_SECRET])
    local info_url; info_url=$(item_url "$remote")
    local dl
    dl=$(curl -fsS "$info_url?\$select=@microsoft.graph.downloadUrl" \
        -H "Authorization: Bearer $token" | jq -r '."@microsoft.graph.downloadUrl"')
    curl -fsS -L "$dl"
}

cmd_url() {
    local remote="${1:?usage: url <path-or-id>}"
    local token; [REDACTED_SECRET])
    local info_url; info_url=$(item_url "$remote")
    curl -fsS "$info_url?\$select=@microsoft.graph.downloadUrl,webUrl,name" \
        -H "Authorization: Bearer $token" \
      | jq '{name, downloadUrl: ."@microsoft.graph.downloadUrl", webUrl}'
}

cmd_rename() {
    local target="${1:?usage: rename <path-or-id> <new-name>}"
    local newname="${2:?usage: rename <path-or-id> <new-name>}"
    local token; [REDACTED_SECRET])
    local url; url=$(item_url "$target")
    curl -fsS -X PATCH "$url" \
        -H "Authorization: Bearer $token" \
        -H "Content-Type: application/json" \
        -d "$(jq -n --arg n "$newname" '{name:$n}')" \
      | jq '{status:"renamed", name, id, url: .webUrl}'
}

cmd_move() {
    # move <source> <dest-folder>
    local src="${1:?usage: move <source-path-or-id> <dest-folder-path-or-id>}"
    local dst="${2:?usage: move <source-path-or-id> <dest-folder-path-or-id>}"
    local token; [REDACTED_SECRET])
    local src_url; src_url=$(item_url "$src")
    local dst_id; dst_id=$(resolve_id "$dst")
    curl -fsS -X PATCH "$src_url" \
        -H "Authorization: Bearer $token" \
        -H "Content-Type: application/json" \
        -d "$(jq -n --arg id "$dst_id" '{parentReference:{id:$id}}')" \
      | jq '{status:"moved", name, id, parent:.parentReference.path}'
}

cmd_copy() {
    # copy <source> <dest-folder> [new-name]
    local src="${1:?usage: copy <source-path-or-id> <dest-folder-path-or-id> [new-name]}"
    local dst="${2:?usage: copy <source-path-or-id> <dest-folder-path-or-id> [new-name]}"
    local newname="${3:-}"
    local token; [REDACTED_SECRET])
    local src_url; src_url=$(item_url "$src" "/copy")
    local dst_id; dst_id=$(resolve_id "$dst")
    local body
    if [ -n "$newname" ]; then
        body=$(jq -n --arg id "$dst_id" --arg n "$newname" '{parentReference:{id:$id}, name:$n}')
    else
        body=$(jq -n --arg id "$dst_id" '{parentReference:{id:$id}}')
    fi
    # Returns 202 Accepted with Location header for monitoring
    local resp
    resp=$(curl -fsS -X POST "$src_url" -D /tmp/.onedrive-copy-headers \
        -H "Authorization: Bearer $token" \
        -H "Content-Type: application/json" \
        -d "$body" || true)
    local monitor
    monitor=$(grep -i '^location:' /tmp/.onedrive-copy-headers | awk '{print $2}' | tr -d '\r\n' || true)
    rm -f /tmp/.onedrive-copy-headers
    echo "{\"status\":\"copy_queued\",\"monitor\":\"$monitor\"}"
}

cmd_delete() {
    local target="${1:?usage: delete <path-or-id>}"
    local token; [REDACTED_SECRET])
    local url; url=$(item_url "$target")
    local code
    code=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE "$url" \
        -H "Authorization: Bearer $token")
    if [ "$code" = "204" ]; then
        echo "{\"status\":\"deleted\",\"target\":\"$target\"}"
    else
        echo "{\"status\":\"failed\",\"http_code\":$code}" >&2
        exit 1
    fi
}

cmd_search() {
    local q="${1:?usage: search <query> [top]}"
    local top="${2:-25}"
    local token; [REDACTED_SECRET])
    local q_enc; q_enc=$(jq -nr --arg v "$q" '$v|@uri')
    curl -fsS "$GRAPH$DRIVE_PREFIX/root/search(q='$q_enc')?\$top=$top&\$select=id,name,size,file,folder,webUrl,parentReference" \
        -H "Authorization: Bearer $token" \
      | jq '.value[] | {kind:(if .folder then "dir" else "file" end), name, size, id, path: .parentReference.path, url: .webUrl}'
}

cmd_tree() {
    # tree [path] [max-depth]
    local path="${1:-}"
    local maxdepth="${2:-3}"
    local token; [REDACTED_SECRET])
    _tree_recurse() {
        local p="$1" d="$2" indent="$3"
        [ "$d" -gt "$maxdepth" ] && return
        local url
        if [ -z "$p" ]; then
            url="$GRAPH$DRIVE_PREFIX/root/children?\$top=200&\$select=id,name,folder,file"
        else
            url=$(item_url "$p" "/children?\$top=200&\$select=id,name,folder,file")
        fi
        local items
        items=$(curl -fsS "$url" -H "Authorization: Bearer $token")
        echo "$items" | jq -r '.value[] | "\(if .folder then "D" else "F" end)\t\(.name)\t\(.id)"' \
            | while IFS=$'\t' read -r kind name id; do
                echo "${indent}${kind} ${name}"
                if [ "$kind" = "D" ]; then
                    local child="$p/$name"; child="${child#/}"
                    _tree_recurse "$child" "$((d + 1))" "$indent  "
                fi
            done
    }
    _tree_recurse "$path" 1 ""
}

cmd_delta() {
    # delta [token]
    local token; [REDACTED_SECRET])
    local delta_arg="${1:-}"
    local url
    if [ -n "$delta_arg" ]; then
        url="$GRAPH$DRIVE_PREFIX/root/delta?[REDACTED_SECRET]"
    else
        url="$GRAPH$DRIVE_PREFIX/root/delta"
    fi
    curl -fsS "$url" -H "Authorization: Bearer $token" | jq
}

cmd_drives() {
    local token; [REDACTED_SECRET])
    curl -fsS "$GRAPH/me/drives" -H "Authorization: Bearer $token" \
      | jq '.value[] | {id, name, driveType, owner: .owner.user.displayName, total: .quota.total, used: .quota.used}'
}

cmd_drive() {
    local drive_id="${1:-}"
    local token; [REDACTED_SECRET])
    local url
    if [ -z "$drive_id" ]; then
        url="$GRAPH$DRIVE_PREFIX"
    else
        url="$GRAPH/drives/$drive_id"
    fi
    curl -fsS "$url" -H "Authorization: Bearer $token" | jq
}

cmd_quota() {
    local token; [REDACTED_SECRET])
    curl -fsS "$GRAPH$DRIVE_PREFIX" -H "Authorization: Bearer $token" \
      | jq '.quota | {state, total, used, remaining, deleted}'
}

cmd_help() {
    cat <<EOF
Usage: onedrive-files.sh <command> [args]

LISTING
  list [path]                List children of root (or path)
  list-id <item-id>          List children by item ID
  list-special <name>        Special folder children (documents|photos|cameraroll|approot|music)
  recent [count]             Recently used files
  shared [count]             Items shared with me
  tree [path] [max-depth=3]  Recursive tree view

INSPECT
  info <path-or-id>          Full metadata (JSON)
  stat <path-or-id>          Compact metadata
  thumbnail <id> [size]      Thumbnail URL (small|medium|large)
  preview <id>               Embeddable preview URL
  versions <id>              Version history

CREATE
  mkdir <path>               Create folder (creates parents)
  upload <local> <remote>    Upload (auto: simple if ≤4MB, else resumable in 10MiB chunks)
  upload-stream <remote>     Pipe stdin to a remote file

READ / DOWNLOAD
  download <remote> [local]  Save to local (default: basename)
  cat <path>                 Stream content to stdout
  url <path>                 Print pre-auth downloadUrl + webUrl

MODIFY
  rename <id-or-path> <new>
  move   <src> <dest-folder>
  copy   <src> <dest-folder> [new-name]
  delete <id-or-path>        Move to recycle bin

SEARCH / SYNC
  search "<query>" [top]
  delta [token]              Initial or incremental changes

DRIVES
  drives                     List accessible drives
  drive [drive-id]           Drive metadata (default: yours)
  quota                      Storage usage

Env overrides:
  ONEDRIVE_ACCESS_TOKEN      Use this token directly (skip credentials file)
  ONEDRIVE_DRIVE_PREFIX      Default '/me/drive'; override with '/drives/{id}' or '/sites/{id}/drive'
  ONEDRIVE_GRAPH_BASE        Default 'https://graph.microsoft.com/v1.0'
  ONEDRIVE_MCP_CONFIG_DIR        Default '~/.onedrive-mcp'
EOF
}

case "${1:-help}" in
    list)          shift; cmd_list "$@" ;;
    list-id)       shift; cmd_list_id "$@" ;;
    list-special)  shift; cmd_list_special "$@" ;;
    recent)        shift; cmd_recent "$@" ;;
    shared)        shift; cmd_shared "$@" ;;
    tree)          shift; cmd_tree "$@" ;;
    info)          shift; cmd_info "$@" ;;
    stat)          shift; cmd_stat "$@" ;;
    thumbnail)     shift; cmd_thumbnail "$@" ;;
    preview)       shift; cmd_preview "$@" ;;
    versions)      shift; cmd_versions "$@" ;;
    mkdir)         shift; cmd_mkdir "$@" ;;
    upload)        shift; cmd_upload "$@" ;;
    upload-stream) shift; cmd_upload_stream "$@" ;;
    download)      shift; cmd_download "$@" ;;
    cat)           shift; cmd_cat "$@" ;;
    url)           shift; cmd_url "$@" ;;
    rename)        shift; cmd_rename "$@" ;;
    move)          shift; cmd_move "$@" ;;
    copy)          shift; cmd_copy "$@" ;;
    delete|rm)     shift; cmd_delete "$@" ;;
    search)        shift; cmd_search "$@" ;;
    delta)         shift; cmd_delta "$@" ;;
    drives)        shift; cmd_drives "$@" ;;
    drive)         shift; cmd_drive "$@" ;;
    quota)         shift; cmd_quota "$@" ;;
    help|--help|-h|"") cmd_help ;;
    *) echo "Unknown command: $1"; cmd_help; exit 1 ;;
esac


path: scripts/onedrive-setup.sh
content: #!/usr/bin/env bash
# onedrive-setup.sh — Automated Azure AD app registration + OAuth flow for OneDrive (Microsoft Graph)
# Requires: az (Azure CLI), jq, curl

set -euo pipefail

CONFIG_DIR="${ONEDRIVE_MCP_CONFIG_DIR:-$HOME/.onedrive-mcp}"
CONFIG_FILE="$CONFIG_DIR/config.json"
CREDS_FILE="$CONFIG_DIR/credentials.json"

APP_NAME="${ONEDRIVE_APP_NAME:-Clawdbot-OneDrive}"
REDIRECT_URI="${ONEDRIVE_REDIRECT_URI:-http://localhost}"
TENANT="${ONEDRIVE_TENANT:-common}"   # common | consumers | organizations | <tenant-id>
SCOPES="${ONEDRIVE_SCOPES:-https://graph.microsoft.com/Files.ReadWrite.All https://graph.microsoft.com/Sites.ReadWrite.All https://graph.microsoft.com/User.Read offline_access}"

RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'

echo -e "${BLUE}=== OneDrive (Microsoft Graph) OAuth Setup ===${NC}"
echo ""

check_prereqs() {
    local missing=()
    command -v az >/dev/null 2>&1 || missing+=("az (Azure CLI)")
    command -v jq >/dev/null 2>&1 || missing+=("jq")
    command -v curl >/dev/null 2>&1 || missing+=("curl")
    if [ ${#missing[@]} -gt 0 ]; then
        echo -e "${RED}Missing prerequisites:${NC} ${missing[*]}"
        echo "  - Azure CLI: curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash"
        echo "  - jq:        sudo apt install jq"
        exit 1
    fi
}

azure_login() {
    echo -e "${YELLOW}Step 1: Azure Login${NC}"
    if az account show >/dev/null 2>&1; then
        local user
        user=$(az account show --query user.name -o tsv)
        echo -e "Currently logged in as: ${GREEN}$user${NC}"
        read -p "Continue with this account? [Y/n] " -n 1 -r REPLY; echo
        if [[ $REPLY =~ ^[Nn]$ ]]; then az logout 2>/dev/null || true; else return 0; fi
    fi
    echo "Using device code login (works headless):"
    az login --use-device-code >/dev/null || { echo -e "${RED}Login failed${NC}"; exit 1; }
    echo -e "${GREEN}✓ Logged in${NC}"
}

create_app() {
    echo ""
    echo -e "${YELLOW}Step 2: Creating App Registration${NC}"
    local existing
    existing=$(az ad app list --display-name "$APP_NAME" --query "[0].appId" -o tsv 2>/dev/null || true)
    if [ -n "$existing" ] && [ "$existing" != "null" ]; then
        echo -e "App '$APP_NAME' exists: ${BLUE}$existing${NC}"
        read -p "Reuse it? [Y/n] " -n 1 -r REPLY; echo
        if [[ ! $REPLY =~ ^[Nn]$ ]]; then
            APP_ID="$existing"
            echo -e "${GREEN}✓ Reusing existing app${NC}"
            return 0
        fi
        APP_NAME="$APP_NAME-$(date +%s)"
        echo "Creating new app: $APP_NAME"
    fi
    APP_ID=$(az ad app create \
        --display-name "$APP_NAME" \
        --sign-in-audience "AzureADandPersonalMicrosoftAccount" \
        --web-redirect-uris "$REDIRECT_URI" \
        --query appId -o tsv)
    echo -e "${GREEN}✓ App created: $APP_ID${NC}"
}

create_secret() {
    echo ""
    echo -e "${YELLOW}Step 3: Creating Client Secret${NC}"
    local result
    result=$(az ad app credential reset --id "$APP_ID" \
        --display-name "onedrive-cli" --years 2 \
        --query "{clientId: appId, clientSecret: password}" -o json)
    CLIENT_ID=$(echo "$result" | jq -r '.clientId')
    CLIENT_SECRET=$(echo "$result" | jq -r '.clientSecret')
    if [ -z "$CLIENT_SECRET" ] || [ "$CLIENT_SECRET" = "null" ]; then
        echo -e "${RED}Failed to create secret${NC}"; exit 1
    fi
    echo -e "${GREEN}✓ Secret created${NC}"
}

add_permissions() {
    echo ""
    echo -e "${YELLOW}Step 4: Adding API Permissions${NC}"
    local GRAPH="00000003-0000-0000-c000-000000000000"
    local files_rw_all sites_rw_all user_read
    files_rw_all=$(az ad sp show --id "$GRAPH" --query "oauth2PermissionScopes[?value=='Files.ReadWrite.All'].id" -o tsv 2>/dev/null || true)
    sites_rw_all=$(az ad sp show --id "$GRAPH" --query "oauth2PermissionScopes[?value=='Sites.ReadWrite.All'].id" -o tsv 2>/dev/null || true)
    user_read=$(az ad sp show --id "$GRAPH" --query "oauth2PermissionScopes[?value=='User.Read'].id" -o tsv 2>/dev/null || true)

    local perms=()
    [ -n "$files_rw_all" ] && perms+=("$files_rw_all=Scope")
    [ -n "$sites_rw_all" ] && perms+=("$sites_rw_all=Scope")
    [ -n "$user_read" ] && perms+=("$user_read=Scope")

    if [ ${#perms[@]} -gt 0 ]; then
        az ad app permission add --id "$APP_ID" --api "$GRAPH" \
            --api-permissions "${perms[@]}" 2>/dev/null || true
    fi
    echo -e "${GREEN}✓ Permissions: Files.ReadWrite.All, Sites.ReadWrite.All, User.Read${NC}"
    echo -e "  (offline_access is requested at sign-in, not declared here)"
}

save_config() {
    echo ""
    echo -e "${YELLOW}Step 5: Saving Configuration${NC}"
    mkdir -p "$CONFIG_DIR" && chmod 700 "$CONFIG_DIR"
    cat > "$CONFIG_FILE" <<EOF
{
  "client_id": "$CLIENT_ID",
  "client_secret": "$CLIENT_SECRET",
  "tenant": "$TENANT",
  "redirect_uri": "$REDIRECT_URI",
  "scopes": "$SCOPES"
}
EOF
    chmod 600 "$CONFIG_FILE"
    echo -e "${GREEN}✓ Config saved to $CONFIG_FILE${NC}"
}

url_encode() {
    python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1],safe=""))' "$1" 2>/dev/null \
        || jq -nr --arg v "$1" '$v|@uri'
}

authorize() {
    echo ""
    echo -e "${YELLOW}Step 6: User Authorization${NC}"
    local enc_scope enc_redirect
    enc_scope=$(url_encode "$SCOPES")
    enc_redirect=$(url_encode "$REDIRECT_URI")
    local auth_url="https://login.microsoftonline.com/$TENANT/oauth2/v2.0/authorize?client_id=$CLIENT_ID&response_type=code&redirect_uri=$enc_redirect&response_mode=query&scope=$enc_scope"

    echo ""
    echo "Open this URL in a browser (the same Microsoft account whose OneDrive you want to use):"
    echo ""
    echo -e "${BLUE}$auth_url${NC}"
    echo ""
    echo "After consent, the browser will be redirected to $REDIRECT_URI?code=... (page won't load)."
    echo "Copy the FULL redirected URL (or just the code parameter) and paste below."
    echo ""
    read -p "Redirected URL or code: " input

    local code
    if [[ "$input" == *"code="* ]]; then
        code=$(echo "$input" | grep -oP 'code=\K[^&]+' || true)
    else
        code="$input"
    fi

    if [ -z "$code" ]; then
        echo -e "${RED}No authorization code found${NC}"; exit 1
    fi

    echo "Exchanging code for tokens..."
    local response
    response=$(curl -s -X POST "https://login.microsoftonline.com/$TENANT/oauth2/v2.0/token" \
        -H "Content-Type: application/x-www-form-urlencoded" \
        --data-urlencode "client_id=$CLIENT_ID" \
        --data-urlencode "client_secret=$CLIENT_SECRET" \
        --data-urlencode "code=$code" \
        --data-urlencode "redirect_uri=$REDIRECT_URI" \
        --data-urlencode "grant_type=authorization_code" \
        --data-urlencode "scope=$SCOPES")

    if echo "$response" | jq -e '.access_token' >/dev/null 2>&1; then
        # Annotate with expiry timestamp for easy local checking
        local now expires_in
        now=$(date +%s)
        expires_in=$(echo "$response" | jq -r '.expires_in // 3600')
        echo "$response" | jq --argjson now "$now" --argjson exp "$expires_in" \
            '. + {acquired_at: $now, expires_at: ($now + $exp)}' > "$CREDS_FILE"
        chmod 600 "$CREDS_FILE"
        echo -e "${GREEN}✓ Tokens saved to $CREDS_FILE${NC}"
    else
        echo -e "${RED}Token exchange failed:${NC}"
        echo "$response" | jq '.error_description // .'
        exit 1
    fi
}

test_connection() {
    echo ""
    echo -e "${YELLOW}Step 7: Testing Connection${NC}"
    local token
    token=$(jq -r '.access_token' "$CREDS_FILE")
    local drive
    drive=$(curl -s "https://graph.microsoft.com/v1.0/me/drive" -H "Authorization: Bearer $token")
    if echo "$drive" | jq -e '.id' >/dev/null 2>&1; then
        local owner used total
        owner=$(echo "$drive" | jq -r '.owner.user.displayName // .owner.user.email // "?"')
        used=$(echo "$drive" | jq -r '.quota.used // 0')
        total=$(echo "$drive" | jq -r '.quota.total // 0')
        echo -e "${GREEN}✓ Connected!${NC}"
        echo -e "  Owner: ${BLUE}$owner${NC}"
        echo -e "  Storage: $used / $total bytes used"
    else
        echo -e "${RED}Connection test failed:${NC}"
        echo "$drive" | jq '.error.message // .'
        exit 1
    fi
}

main() {
    check_prereqs
    azure_login
    create_app
    create_secret
    add_permissions
    save_config
    authorize
    test_connection
    echo ""
    echo -e "${GREEN}=== Setup complete ===${NC}"
    echo ""
    echo "Try:"
    echo "  ./scripts/onedrive-files.sh list"
    echo "  ./scripts/onedrive-files.sh quota"
    echo "  ./scripts/onedrive-token.sh refresh"
}

main "$@"


path: scripts/onedrive-share.sh
content: #!/usr/bin/env bash
# onedrive-share.sh — Sharing, permissions, and share-URL resolution for OneDrive
#
# Usage: onedrive-share.sh <command> [args]

set -euo pipefail

CONFIG_DIR="${ONEDRIVE_MCP_CONFIG_DIR:-$HOME/.onedrive-mcp}"
CREDS_FILE="$CONFIG_DIR/credentials.json"
GRAPH="${ONEDRIVE_GRAPH_BASE:-https://graph.microsoft.com/v1.0}"
DRIVE_PREFIX="${ONEDRIVE_DRIVE_PREFIX:-/me/drive}"

get_token() {
    if [ -n "${ONEDRIVE_ACCESS_TOKEN:-}" ]; then echo "$ONEDRIVE_ACCESS_TOKEN"; return; fi
    [ -f "$CREDS_FILE" ] || { echo "Error: no token. Run onedrive-setup.sh or set ONEDRIVE_ACCESS_TOKEN" >&2; exit 1; }
    jq -r '.access_token // empty' "$CREDS_FILE"
}

url_encode_path() {
    python3 -c '
import sys, urllib.parse
p = sys.argv[1].strip("/")
print("/".join(urllib.parse.quote(seg, safe="") for seg in p.split("/")))
' "$1" 2>/dev/null || echo "$1"
}

item_url() {
    local target="$1" suffix="${2:-}"
    if [[ "$target" =~ ^[A-Za-z0-9!_-]{8,}$ ]] && [[ "$target" != */* ]]; then
        echo "$GRAPH$DRIVE_PREFIX/items/$target$suffix"
    else
        local enc; enc=$(url_encode_path "$target")
        if [ -z "$suffix" ]; then
            echo "$GRAPH$DRIVE_PREFIX/root:/$enc"
        else
            echo "$GRAPH$DRIVE_PREFIX/root:/$enc:$suffix"
        fi
    fi
}

# ===== link <target> <type> <scope> [--password X --expiry YYYY-MM-DD] =====
cmd_link() {
    local target="${1:?usage: link <path-or-id> <type> <scope> [--password X] [--expiry YYYY-MM-DD]}"
    local type="${2:?type: view|edit|embed}"
    local scope="${3:?scope: anonymous|organization|users}"
    shift 3
    local password="" expiry=""
    while [ $# -gt 0 ]; do
        case "$1" in
            --password) password="$2"; shift 2 ;;
            --expiry)   expiry="$2"; shift 2 ;;
            *) echo "Unknown option: $1" >&2; exit 1 ;;
        esac
    done

    local body
    body=$(jq -n --arg t "$type" --arg s "$scope" '{type:$t, scope:$s}')
    if [ -n "$password" ]; then
        body=$(echo "$body" | jq --arg p "$password" '. + {password:$p}')
    fi
    if [ -n "$expiry" ]; then
        # API expects ISO 8601 datetime; add midnight UTC if user gave a date
        local iso="$expiry"
        [[ "$iso" != *"T"* ]] && iso="${iso}T00:00:00Z"
        body=$(echo "$body" | jq --arg e "$iso" '. + {expirationDateTime:$e}')
    fi

    local token; [REDACTED_SECRET])
    local url; url=$(item_url "$target" "/createLink")
    curl -fsS -X POST "$url" \
        -H "Authorization: Bearer $token" \
        -H "Content-Type: application/json" \
        -d "$body" \
      | jq '{id, type: .link.type, scope: .link.scope, webUrl: .link.webUrl, expirationDateTime, hasPassword}'
}

# ===== invite <target> <emails-comma-sep> <role> [message] [--no-notify] =====
cmd_invite() {
    local target="${1:?usage: invite <path-or-id> <emails> <role> [message] [--no-notify]}"
    local emails="${2:?emails: comma-separated list}"
    local role="${3:?role: read | write}"
    local message="${4:-}"
    local send="true"
    [ "${5:-}" = "--no-notify" ] && send="false"

    local recipients
    recipients=$(echo "$emails" | jq -Rc 'split(",") | map({email:(.|gsub("^\\s+|\\s+$";""))})')

    local body
    body=$(jq -n \
        --argjson r "$recipients" \
        --arg role "$role" \
        --arg msg "$message" \
        --argjson send "$send" \
        '{recipients:$r, roles:[$role], sendInvitation:$send, requireSignIn:true, message:$msg}')

    local token; [REDACTED_SECRET])
    local url; url=$(item_url "$target" "/invite")
    curl -fsS -X POST "$url" \
        -H "Authorization: Bearer $token" \
        -H "Content-Type: application/json" \
        -d "$body" \
      | jq '.value[] | {id, roles, grantedTo: (.grantedToV2.user.email // .grantedToV2.user.displayName)}'
}

# ===== permissions <target> =====
cmd_permissions() {
    local target="${1:?usage: permissions <path-or-id>}"
    local token; [REDACTED_SECRET])
    local url; url=$(item_url "$target" "/permissions")
    curl -fsS "$url" -H "Authorization: Bearer $token" \
      | jq '.value[] | {
            id,
            roles,
            type: (.link.type // (if .invitation then "invitation" else "user" end)),
            scope: .link.scope,
            grantedTo: (.grantedToV2.user.email // .grantedToV2.user.displayName // .invitation.email // null),
            link: .link.webUrl,
            expirationDateTime
        }'
}

# ===== revoke <target> <perm-id> =====
cmd_revoke() {
    local target="${1:?usage: revoke <path-or-id> <perm-id>}"
    local perm="${2:?usage: revoke <path-or-id> <perm-id>}"
    local token; [REDACTED_SECRET])
    local url; url=$(item_url "$target" "/permissions/$perm")
    local code
    code=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE "$url" \
        -H "Authorization: Bearer $token")
    if [ "$code" = "204" ]; then
        echo "{\"status\":\"revoked\",\"perm\":\"$perm\"}"
    else
        echo "{\"status\":\"failed\",\"http_code\":$code}" >&2
        exit 1
    fi
}

# ===== update-role <target> <perm-id> <new-role> =====
cmd_update_role() {
    local target="${1:?usage: update-role <path-or-id> <perm-id> <new-role>}"
    local perm="${2:?usage}" role="${3:?usage}"
    local token; [REDACTED_SECRET])
    local url; url=$(item_url "$target" "/permissions/$perm")
    curl -fsS -X PATCH "$url" \
        -H "Authorization: Bearer $token" \
        -H "Content-Type: application/json" \
        -d "$(jq -n --arg r "$role" '{roles:[$r]}')" \
      | jq '{id, roles}'
}

# ===== open <share-url> — resolve a sharing URL to a DriveItem =====
cmd_open() {
    local share_url="${1:?usage: open <share-url>}"
    # Encode: u!<base64url(URL)>  (strip padding)
    local share_id
    share_id="u!$(echo -n "$share_url" | base64 -w0 2>/dev/null | tr '+/' '-_' | tr -d '=')"
    local token; [REDACTED_SECRET])
    curl -fsS "$GRAPH/shares/$share_id/driveItem" \
        -H "Authorization: Bearer $token" \
      | jq
}

# ===== owner <target> — show creator / owner =====
cmd_owner() {
    local target="${1:?usage: owner <path-or-id>}"
    local token; [REDACTED_SECRET])
    local url; url=$(item_url "$target")
    curl -fsS "$url?\$select=createdBy,lastModifiedBy,name,id" \
        -H "Authorization: Bearer $token" \
      | jq '{
            name,
            id,
            createdBy: (.createdBy.user.displayName // .createdBy.user.email),
            lastModifiedBy: (.lastModifiedBy.user.displayName // .lastModifiedBy.user.email)
        }'
}

cmd_help() {
    cat <<EOF
Usage: onedrive-share.sh <command> [args]

LINKS
  link <path-or-id> <type> <scope> [--password X] [--expiry YYYY-MM-DD]
       type:  view | edit | embed
       scope: anonymous | organization | users

INVITE
  invite <path-or-id> <emails> <role> [message] [--no-notify]
       emails: comma-separated ([REDACTED_SECRET],[REDACTED_SECRET])
       role:   read | write

PERMISSIONS
  permissions <path-or-id>             List all permissions on an item
  revoke <path-or-id> <perm-id>        Delete a permission
  update-role <path-or-id> <perm-id> <new-role>

RESOLVE
  open <share-url>                     Look up a 1drv.ms / SharePoint share URL → DriveItem

INFO
  owner <path-or-id>                   Show creator / last modifier

Env overrides:
  ONEDRIVE_ACCESS_TOKEN, ONEDRIVE_DRIVE_PREFIX, ONEDRIVE_GRAPH_BASE
EOF
}

case "${1:-help}" in
    link)         shift; cmd_link "$@" ;;
    invite)       shift; cmd_invite "$@" ;;
    permissions)  shift; cmd_permissions "$@" ;;
    revoke)       shift; cmd_revoke "$@" ;;
    update-role)  shift; cmd_update_role "$@" ;;
    open)         shift; cmd_open "$@" ;;
    owner)        shift; cmd_owner "$@" ;;
    help|--help|-h|"") cmd_help ;;
    *) echo "Unknown command: $1"; cmd_help; exit 1 ;;
esac


path: scripts/onedrive-token.sh
content: #!/usr/bin/env bash
# onedrive-token.sh — Manage Microsoft OAuth2 access tokens for OneDrive
# Usage: onedrive-token.sh [get|refresh|test|info|me|set|clear]

set -euo pipefail

CONFIG_DIR="${ONEDRIVE_MCP_CONFIG_DIR:-$HOME/.onedrive-mcp}"
CONFIG_FILE="$CONFIG_DIR/config.json"
CREDS_FILE="$CONFIG_DIR/credentials.json"

# get_token: load from $ONEDRIVE_ACCESS_TOKEN first, else creds file
get_token() {
    if [ -n "${ONEDRIVE_ACCESS_TOKEN:-}" ]; then
        echo "$ONEDRIVE_ACCESS_TOKEN"
        return 0
    fi
    if [ ! -f "$CREDS_FILE" ]; then
        echo "Error: no credentials file at $CREDS_FILE — run onedrive-setup.sh or set ONEDRIVE_ACCESS_TOKEN" >&2
        exit 1
    fi
    jq -r '.access_token // empty' "$CREDS_FILE"
}

ensure_config() {
    [ -f "$CONFIG_FILE" ] || { echo "Error: missing $CONFIG_FILE — run onedrive-setup.sh" >&2; exit 1; }
    [ -f "$CREDS_FILE" ] || { echo "Error: missing $CREDS_FILE — run onedrive-setup.sh" >&2; exit 1; }
}

cmd_get() {
    get_token
}

cmd_refresh() {
    ensure_config
    local cid csec tenant scopes rt
    cid=$(jq -r '.client_id' "$CONFIG_FILE")
    csec=$(jq -r '.client_secret' "$CONFIG_FILE")
    tenant=$(jq -r '.tenant // "common"' "$CONFIG_FILE")
    scopes=$(jq -r '.scopes' "$CONFIG_FILE")
    rt=$(jq -r '.refresh_token // empty' "$CREDS_FILE")

    if [ -z "$rt" ]; then
        echo "Error: no refresh_token in $CREDS_FILE — re-run onedrive-setup.sh" >&2
        exit 1
    fi

    local response
    response=$(curl -s -X POST "https://login.microsoftonline.com/$tenant/oauth2/v2.0/token" \
        -H "Content-Type: application/x-www-form-urlencoded" \
        --data-urlencode "client_id=$cid" \
        --data-urlencode "client_secret=$csec" \
        --data-urlencode "refresh_token=$rt" \
        --data-urlencode "grant_type=refresh_token" \
        --data-urlencode "scope=$scopes")

    if echo "$response" | jq -e '.access_token' >/dev/null 2>&1; then
        local now exp
        now=$(date +%s)
        exp=$(echo "$response" | jq -r '.expires_in // 3600')
        echo "$response" | jq --argjson now "$now" --argjson exp "$exp" \
            '. + {acquired_at: $now, expires_at: ($now + $exp)}' > "$CREDS_FILE"
        chmod 600 "$CREDS_FILE"
        echo "{\"status\":\"refreshed\",\"expires_in\":$exp}"
    else
        echo "$response" | jq '{error: (.error // "unknown"), description: (.error_description // .)}' >&2
        exit 1
    fi
}

cmd_test() {
    local token
    [REDACTED_SECRET])
    local r
    r=$(curl -s -o /dev/null -w "%{http_code}" "https://graph.microsoft.com/v1.0/me/drive" \
        -H "Authorization: Bearer $token")
    if [ "$r" = "200" ]; then
        echo '{"status":"ok"}'
    else
        echo "{\"status\":\"failed\",\"http_code\":$r}" >&2
        exit 1
    fi
}

cmd_info() {
    [ -f "$CREDS_FILE" ] || { echo "Error: missing $CREDS_FILE" >&2; exit 1; }
    jq '{
        token_type,
        scope,
        expires_in,
        acquired_at,
        expires_at,
        seconds_remaining: ((.expires_at // 0) - now | floor),
        has_refresh_token: (.refresh_token != null)
    }' "$CREDS_FILE"
}

cmd_me() {
    local token
    [REDACTED_SECRET])
    curl -s "https://graph.microsoft.com/v1.0/me" \
        -H "Authorization: Bearer $token" \
        | jq '{id, displayName, userPrincipalName, mail, givenName, surname, jobTitle, officeLocation}'
}

cmd_set() {
    # onedrive-token.sh set <access_token> [refresh_token]
    local at="${1:-}" rt="${2:-}"
    if [ -z "$at" ]; then
        echo "Usage: onedrive-token.sh set <access_token> [refresh_token]" >&2
        exit 1
    fi
    mkdir -p "$CONFIG_DIR" && chmod 700 "$CONFIG_DIR"
    local now=$(date +%s)
    jq -n --arg at "$at" --arg rt "$rt" --argjson now "$now" \
        '{access_token:$at, refresh_token:(if $rt=="" then null else $rt end), token_type:"Bearer", expires_in:3600, acquired_at:$now, expires_at:($now+3600)}' \
        > "$CREDS_FILE"
    chmod 600 "$CREDS_FILE"
    echo "{\"status\":\"saved\",\"file\":\"$CREDS_FILE\"}"
}

cmd_clear() {
    rm -f "$CREDS_FILE"
    echo "{\"status\":\"cleared\"}"
}

case "${1:-}" in
    get)     cmd_get ;;
    refresh) cmd_refresh ;;
    test)    cmd_test ;;
    info)    cmd_info ;;
    me)      cmd_me ;;
    set)     shift; cmd_set "$@" ;;
    clear)   cmd_clear ;;
    *)
        cat <<EOF
Usage: onedrive-token.sh <command>

  get       Print current access token
  refresh   Exchange refresh_token for a new access_token
  test      Verify token against /me/drive (HTTP 200 = ok)
  info      Show token type, scopes, expiration
  me        Show signed-in user (displayName, email, ...)
  set <at> [<rt>]  Manually store an access (and optional refresh) token
  clear     Remove the credentials file

Env overrides:
  ONEDRIVE_ACCESS_TOKEN — short-circuit to use this token (read-only)
  ONEDRIVE_MCP_CONFIG_DIR   — directory for config + credentials (default ~/.onedrive-mcp)
EOF
        ;;
esac


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal:  | SkillSpector: suspicious (CRITICAL)