Initial import: SharePoint Lists skill
Claude Code skill for SharePoint Lists CRUD via Microsoft Graph (app-only auth): reusable graph.mjs client, sp.mjs CLI, and setup + API reference docs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
11
.claude/skills/sharepoint-lists/.env.example
Normal file
11
.claude/skills/sharepoint-lists/.env.example
Normal file
@@ -0,0 +1,11 @@
|
||||
# Copy this file to ".env" (same directory) and fill in real values.
|
||||
# The CLI loads .env automatically. NEVER commit the real .env (it is git-ignored).
|
||||
#
|
||||
# From the Azure AD (Entra) app registration — see references/setup.md.
|
||||
|
||||
SP_TENANT_ID=00000000-0000-0000-0000-000000000000
|
||||
SP_CLIENT_ID=00000000-0000-0000-0000-000000000000
|
||||
SP_CLIENT_SECRET=your-client-secret-value-here
|
||||
|
||||
# Optional: default site so you can omit --site on every command.
|
||||
# SP_SITE_URL=https://contoso.sharepoint.com/sites/Marketing
|
||||
3
.claude/skills/sharepoint-lists/.gitignore
vendored
Normal file
3
.claude/skills/sharepoint-lists/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Never commit real credentials.
|
||||
.env
|
||||
*.env.local
|
||||
155
.claude/skills/sharepoint-lists/SKILL.md
Normal file
155
.claude/skills/sharepoint-lists/SKILL.md
Normal file
@@ -0,0 +1,155 @@
|
||||
---
|
||||
name: sharepoint-lists
|
||||
description: >-
|
||||
Read and write Microsoft SharePoint Lists via the Microsoft Graph API using
|
||||
app-only (client-credentials) authentication. Use this skill whenever the user
|
||||
wants to query, search, list, create, add, update, edit, or delete items in a
|
||||
SharePoint List, inspect a list's columns, or enumerate the lists in a
|
||||
SharePoint site — even if they just refer to "our SharePoint", "the list",
|
||||
"the tracker", or a specific list/site name rather than saying "SharePoint
|
||||
List" explicitly. Also use it for bulk operations over list items (export,
|
||||
reporting, batch updates) and for connectivity/credential troubleshooting
|
||||
against SharePoint. This is for SharePoint *Lists* (structured rows/columns),
|
||||
not document libraries or files — for documents use the SharePoint search
|
||||
connector instead.
|
||||
---
|
||||
|
||||
# SharePoint Lists
|
||||
|
||||
Connect to Microsoft SharePoint Lists and perform full CRUD (create, read,
|
||||
update, delete) on list items through the Microsoft Graph API. Authentication is
|
||||
**app-only** (OAuth2 client credentials) — no interactive login.
|
||||
|
||||
All operations go through one CLI, `scripts/sp.mjs`, which wraps the reusable
|
||||
core client in `scripts/lib/graph.mjs`. Run it with Node (already available):
|
||||
|
||||
```bash
|
||||
node scripts/sp.mjs <command> [flags]
|
||||
```
|
||||
|
||||
> Paths above are relative to this skill's directory. From elsewhere, use the
|
||||
> absolute path, e.g. `node "<skill-dir>/scripts/sp.mjs" ...`.
|
||||
|
||||
## Prerequisites (one-time)
|
||||
|
||||
The skill needs three credentials from an Azure AD (Entra) **app registration**
|
||||
with an *application* permission for SharePoint and admin consent granted:
|
||||
|
||||
- `SP_TENANT_ID` — Directory (tenant) ID
|
||||
- `SP_CLIENT_ID` — Application (client) ID
|
||||
- `SP_CLIENT_SECRET` — a client secret value
|
||||
|
||||
Set them as environment variables, or copy `.env.example` to `.env` in this
|
||||
skill's directory and fill it in (the CLI loads `.env` automatically; `.env` is
|
||||
git-ignored). Optionally set `SP_SITE_URL` to a default site so `--site` can be
|
||||
omitted.
|
||||
|
||||
**If credentials are not yet set up**, do not guess — point the user to
|
||||
[references/setup.md](references/setup.md), which is a step-by-step walkthrough
|
||||
of creating the app registration, choosing least-privilege permissions
|
||||
(`Sites.Selected` is strongly preferred over `Sites.ReadWrite.All`), and granting
|
||||
consent. Then come back and run `test`.
|
||||
|
||||
## First step in any task: verify connectivity
|
||||
|
||||
Before doing real work, confirm auth works. This fails fast with a clear message
|
||||
if credentials or permissions are wrong:
|
||||
|
||||
```bash
|
||||
node scripts/sp.mjs test --site "https://contoso.sharepoint.com/sites/Marketing"
|
||||
```
|
||||
|
||||
A successful response includes the resolved site id and the names of the lists
|
||||
in that site.
|
||||
|
||||
## Identifying a site and a list
|
||||
|
||||
- **Site** is given by URL: `https://{tenant}.sharepoint.com/sites/{SiteName}`.
|
||||
The client resolves it to a Graph site id for you. A bare hostname means the
|
||||
root site.
|
||||
- **List** can be given by display name (e.g. `"Project Tracker"`), internal
|
||||
name, or GUID — the client resolves names to ids and, on a typo, lists the
|
||||
available names so you can correct it.
|
||||
|
||||
When unsure what lists or columns exist, discover first rather than guessing:
|
||||
|
||||
```bash
|
||||
node scripts/sp.mjs lists --site "<SITE_URL>"
|
||||
node scripts/sp.mjs columns --site "<SITE_URL>" --list "Project Tracker"
|
||||
```
|
||||
|
||||
`columns` is important before writing: SharePoint **internal** column names
|
||||
often differ from their display names (a column shown as "Due Date" may be
|
||||
`DueDate` or even `OData__x0044_ue...`). Always write to internal names. See
|
||||
[references/graph-api.md](references/graph-api.md) for field-type formats
|
||||
(person, lookup, choice, date, etc.) and OData query details.
|
||||
|
||||
## Reading items
|
||||
|
||||
```bash
|
||||
# Everything (follows pagination):
|
||||
node scripts/sp.mjs items --site "<SITE_URL>" --list "Project Tracker" --all
|
||||
|
||||
# Filtered + sorted + projected:
|
||||
node scripts/sp.mjs items --site "<SITE_URL>" --list "Project Tracker" \
|
||||
--filter "fields/Status eq 'Open'" \
|
||||
--select "Title,Status,AssignedTo" \
|
||||
--orderby "fields/DueDate asc" --top 50
|
||||
|
||||
# One item by id:
|
||||
node scripts/sp.mjs get --site "<SITE_URL>" --list "Project Tracker" --id 42
|
||||
```
|
||||
|
||||
Items come back simplified as `{ id, webUrl, createdDateTime, lastModifiedDateTime, fields }`.
|
||||
|
||||
## Writing items
|
||||
|
||||
`--fields` takes a JSON object of internal column name → value. It also accepts
|
||||
`@file.json` or `-` (read JSON from stdin), which is cleaner for large or
|
||||
quote-heavy payloads.
|
||||
|
||||
```bash
|
||||
# Create
|
||||
node scripts/sp.mjs create --site "<SITE_URL>" --list "Project Tracker" \
|
||||
--fields '{"Title":"Migrate database","Status":"Open","Priority":2}'
|
||||
|
||||
# Update (partial — only the fields you pass change)
|
||||
node scripts/sp.mjs update --site "<SITE_URL>" --list "Project Tracker" \
|
||||
--id 42 --fields '{"Status":"Done"}'
|
||||
|
||||
# Delete
|
||||
node scripts/sp.mjs delete --site "<SITE_URL>" --list "Project Tracker" --id 42
|
||||
```
|
||||
|
||||
**Deletes are irreversible (items go to the site Recycle Bin). Confirm the item
|
||||
id with the user before deleting**, and prefer showing them the item via `get`
|
||||
first. For bulk deletes, confirm the full set once, then proceed.
|
||||
|
||||
## Bulk operations
|
||||
|
||||
For exports or batch edits, fetch with `--all`, transform the JSON however the
|
||||
task needs (jq, a quick script), then loop the relevant `create`/`update`/
|
||||
`delete` calls. Keep concurrency modest — Graph throttles with HTTP 429; the
|
||||
client already retries with backoff, but don't fan out hundreds of parallel
|
||||
writes.
|
||||
|
||||
## Handling errors
|
||||
|
||||
The CLI prints a JSON error to stderr and exits non-zero. Common cases:
|
||||
|
||||
- `401` / token failure → bad client id/secret or tenant; re-check `.env`.
|
||||
- `403` (`accessDenied`) → the app lacks permission on this site. With
|
||||
`Sites.Selected`, the site must be explicitly granted to the app — see
|
||||
[references/setup.md](references/setup.md).
|
||||
- `404` on a list → wrong name; run `lists` to see valid names.
|
||||
- `400` on write → usually a wrong internal field name or value format; run
|
||||
`columns` and check [references/graph-api.md](references/graph-api.md).
|
||||
|
||||
## Extending to an MCP server
|
||||
|
||||
The core logic is isolated in `scripts/lib/graph.mjs` (the
|
||||
`SharePointListsClient` class, zero dependencies). To graduate this into a
|
||||
standalone MCP server later, import that class and expose its methods
|
||||
(`listItems`, `getItem`, `createItem`, `updateItem`, `deleteItem`, etc.) as MCP
|
||||
tools — no rewrite of the Graph logic needed. See the note at the bottom of
|
||||
[references/setup.md](references/setup.md).
|
||||
112
.claude/skills/sharepoint-lists/references/graph-api.md
Normal file
112
.claude/skills/sharepoint-lists/references/graph-api.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Graph API reference for SharePoint List items
|
||||
|
||||
Quick reference for the parts of the Microsoft Graph Lists API that this skill
|
||||
relies on. Full docs: <https://learn.microsoft.com/graph/api/resources/listitem>.
|
||||
|
||||
## Internal vs. display column names
|
||||
|
||||
Graph reads and writes use a column's **internal name**, which is frequently NOT
|
||||
the display name you see in the UI:
|
||||
|
||||
- Spaces and special characters are encoded: "Due Date" → `DueDate`; a column
|
||||
later renamed keeps its original internal name; some become `OData__x005f...`.
|
||||
- Always discover internal names before writing:
|
||||
`node scripts/sp.mjs columns --site "<URL>" --list "<NAME>"`.
|
||||
- In the `columns` output, the `name` field is the internal name; `displayName`
|
||||
is what the UI shows. Write to `name`.
|
||||
|
||||
`Title` is the built-in primary text column on most lists.
|
||||
|
||||
## Field value formats by column type
|
||||
|
||||
When creating/updating, `--fields` is a JSON object of internal name → value:
|
||||
|
||||
| Column type | JSON value | Example |
|
||||
|---|---|---|
|
||||
| Single line / multi-line text | string | `{"Title":"Hello"}` |
|
||||
| Number / Currency | number | `{"Estimate":3.5}` |
|
||||
| Yes/No (boolean) | boolean | `{"Approved":true}` |
|
||||
| Date / DateTime | ISO 8601 string (UTC) | `{"DueDate":"2026-07-01T00:00:00Z"}` |
|
||||
| Choice (single) | the choice string | `{"Status":"In Progress"}` |
|
||||
| Choice (multi) | needs an OData type hint (see below) | |
|
||||
| Hyperlink | `{ "Url": "...", "Description": "..." }` | `{"Link":{"Url":"https://x","Description":"X"}}` |
|
||||
| Lookup (single) | set `<Name>LookupId` to the target item id | `{"CategoryLookupId":7}` |
|
||||
| Lookup (multi) | OData type hint + array of ids (see below) | |
|
||||
| Person/Group (single) | set `<Name>LookupId` to the user's lookup id | `{"AssignedToLookupId":12}` |
|
||||
|
||||
### Multi-value fields need an `@odata.type` annotation
|
||||
|
||||
Graph requires you to declare the collection type alongside the value. Include
|
||||
**both** keys in the fields object:
|
||||
|
||||
```json
|
||||
{
|
||||
"Categories@odata.type": "Collection(Edm.String)",
|
||||
"Categories": ["Marketing", "Sales"],
|
||||
|
||||
"RelatedItemsLookupId@odata.type": "Collection(Edm.Int32)",
|
||||
"RelatedItemsLookupId": [3, 9, 14]
|
||||
}
|
||||
```
|
||||
|
||||
### Person / Lookup ids
|
||||
|
||||
Person and lookup columns store an **integer id**, not a name/email. The internal
|
||||
field name is usually `<DisplayInternalName>LookupId`. Getting the right id:
|
||||
|
||||
- **Lookup**: the id is the target list item's `id`. Query the source list to
|
||||
find it.
|
||||
- **Person**: the id is the user's row id in the site's hidden *User Information
|
||||
List*. This is awkward to obtain via Graph alone; if you need to set people
|
||||
fields by email/name often, that's a good reason to add a resolver helper (or
|
||||
do it via the SharePoint REST `ensureUser` endpoint). Flag this to the user
|
||||
rather than guessing an id.
|
||||
|
||||
If a write returns `400 invalidRequest` or `400 generalException`, the field
|
||||
name or one of these value formats is almost always the cause.
|
||||
|
||||
## Querying items (OData)
|
||||
|
||||
The CLI maps flags to OData query options on
|
||||
`/sites/{site}/lists/{list}/items`:
|
||||
|
||||
- `--filter` → `$filter`. Filter on fields with the `fields/` prefix:
|
||||
- `fields/Status eq 'Open'`
|
||||
- `fields/Priority ge 2`
|
||||
- `fields/Title eq 'Exact'` (text is case-sensitive in eq)
|
||||
- `startswith(fields/Title,'Mig')`
|
||||
- combine with `and` / `or`: `fields/Status eq 'Open' and fields/Priority ge 2`
|
||||
- `--orderby` → `$orderby`, e.g. `fields/DueDate asc` or `fields/Created desc`.
|
||||
- `--select` → projects which fields come back: `Title,Status,DueDate`.
|
||||
- `--top` → page size; `--all` follows `@odata.nextLink` to return every page.
|
||||
|
||||
### Non-indexed columns
|
||||
|
||||
SharePoint only allows server-side filter/sort on **indexed** columns unless you
|
||||
opt into a best-effort mode. The client automatically sends
|
||||
`Prefer: HonorNonIndexedQueriesWarningMayFailRandomly` whenever `--filter` or
|
||||
`--orderby` is present, which lets queries on non-indexed columns run (they may
|
||||
occasionally fail on very large lists — add a column index in SharePoint for
|
||||
heavy use). If a filter intermittently errors on a big list, that's why.
|
||||
|
||||
### Escaping quotes
|
||||
|
||||
OData string literals use single quotes; a literal single quote is doubled:
|
||||
`fields/Title eq 'O''Brien'`. In the shell, wrap the whole `--filter` value in
|
||||
double quotes.
|
||||
|
||||
## Response shape
|
||||
|
||||
Reads return items simplified to:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "42",
|
||||
"webUrl": "https://contoso.sharepoint.com/sites/.../DispForm.aspx?ID=42",
|
||||
"createdDateTime": "2026-06-01T10:00:00Z",
|
||||
"lastModifiedDateTime": "2026-06-10T12:30:00Z",
|
||||
"fields": { "Title": "...", "Status": "Open", "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
`id` is the item id you pass to `get`, `update`, and `delete`.
|
||||
124
.claude/skills/sharepoint-lists/references/setup.md
Normal file
124
.claude/skills/sharepoint-lists/references/setup.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Setup: Azure AD app registration for app-only SharePoint access
|
||||
|
||||
This skill authenticates as an **application** (no user sign-in) using the OAuth2
|
||||
client-credentials flow. You create an Entra (Azure AD) app registration once,
|
||||
grant it permission to SharePoint, and hand the skill three secrets.
|
||||
|
||||
You need an Entra **admin** (or someone who is) to grant admin consent and, for
|
||||
the least-privilege option, to grant the app access to specific sites.
|
||||
|
||||
## 1. Register the application
|
||||
|
||||
1. Go to <https://entra.microsoft.com> → **Identity** → **Applications** →
|
||||
**App registrations** → **New registration**.
|
||||
2. Name it something recognizable, e.g. `claude-sharepoint-lists`.
|
||||
3. Supported account types: **Accounts in this organizational directory only**
|
||||
(single tenant) is correct for app-only.
|
||||
4. Leave **Redirect URI** blank — client-credentials doesn't use one.
|
||||
5. **Register**.
|
||||
|
||||
On the app's **Overview** page, copy:
|
||||
- **Application (client) ID** → `SP_CLIENT_ID`
|
||||
- **Directory (tenant) ID** → `SP_TENANT_ID`
|
||||
|
||||
## 2. Create a client secret
|
||||
|
||||
1. App → **Certificates & secrets** → **Client secrets** → **New client secret**.
|
||||
2. Set an expiry (e.g. 6–12 months — note when it expires; you'll have to rotate).
|
||||
3. Copy the secret **Value** immediately (it's only shown once) → `SP_CLIENT_SECRET`.
|
||||
|
||||
> Certificates are more secure than secrets for production. This skill uses a
|
||||
> secret for simplicity; swapping to a certificate is a future enhancement.
|
||||
|
||||
## 3. Grant a Microsoft Graph application permission
|
||||
|
||||
App → **API permissions** → **Add a permission** → **Microsoft Graph** →
|
||||
**Application permissions**. Pick **one** of:
|
||||
|
||||
| Permission | Scope | Use when |
|
||||
|---|---|---|
|
||||
| **`Sites.Selected`** | Only sites an admin explicitly grants (see step 4) | **Preferred.** Least privilege — the app can touch only the sites you allow. |
|
||||
| `Sites.ReadWrite.All` | Read/write items in **all** site collections | Simpler, but broad. Use only if `Sites.Selected` isn't workable. |
|
||||
|
||||
Then click **Grant admin consent for <tenant>** and confirm the status shows
|
||||
a green check. Without consent, every call returns `401`/`403`.
|
||||
|
||||
## 4. (Only for `Sites.Selected`) Grant the app access to a site
|
||||
|
||||
`Sites.Selected` grants nothing until an admin authorizes the app on each site.
|
||||
Do this per site you want the skill to use. Two ways:
|
||||
|
||||
**Option A — PnP PowerShell (simplest for admins):**
|
||||
|
||||
```powershell
|
||||
Install-Module PnP.PowerShell -Scope CurrentUser # once
|
||||
Connect-PnPOnline -Url "https://contoso.sharepoint.com/sites/Marketing" -Interactive
|
||||
Grant-PnPAzureADAppSitePermission `
|
||||
-AppId "<SP_CLIENT_ID>" `
|
||||
-DisplayName "claude-sharepoint-lists" `
|
||||
-Site "https://contoso.sharepoint.com/sites/Marketing" `
|
||||
-Permissions Write # Read | Write | Manage | FullControl
|
||||
```
|
||||
|
||||
**Option B — Microsoft Graph REST** (e.g. via Graph Explorer signed in as an
|
||||
admin with `Sites.FullControl.All` delegated). First get the site id, then grant:
|
||||
|
||||
```http
|
||||
GET https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com:/sites/Marketing
|
||||
POST https://graph.microsoft.com/v1.0/sites/{siteId}/permissions
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"roles": ["write"],
|
||||
"grantedToIdentities": [
|
||||
{ "application": { "id": "<SP_CLIENT_ID>", "displayName": "claude-sharepoint-lists" } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Use `read` instead of `write` for read-only access to that site.
|
||||
|
||||
## 5. Give the skill the credentials
|
||||
|
||||
Copy `.env.example` to `.env` in the skill directory and fill in the three
|
||||
values (and optionally `SP_SITE_URL`). The CLI loads `.env` automatically, and
|
||||
`.env` is git-ignored so secrets aren't committed. Alternatively, export them as
|
||||
environment variables.
|
||||
|
||||
## 6. Verify
|
||||
|
||||
```bash
|
||||
node scripts/sp.mjs test --site "https://contoso.sharepoint.com/sites/Marketing"
|
||||
```
|
||||
|
||||
Expected: `{ "ok": true, "siteId": "...", "listCount": N, "lists": [...] }`.
|
||||
|
||||
Troubleshooting:
|
||||
- **Token request failed (401)** — wrong tenant id, client id, or secret (or the
|
||||
secret expired). Re-copy from the portal.
|
||||
- **403 accessDenied** — admin consent not granted (step 3), or with
|
||||
`Sites.Selected` the site wasn't granted to the app (step 4).
|
||||
- **Works for `test` (no site) but 403 with `--site`** — classic `Sites.Selected`
|
||||
signal: token is fine, site grant is missing.
|
||||
|
||||
---
|
||||
|
||||
## Note: graduating to an MCP server
|
||||
|
||||
When you want this available across all sessions as native tools rather than a
|
||||
CLI, build a small MCP server that imports `scripts/lib/graph.mjs`:
|
||||
|
||||
```js
|
||||
import { SharePointListsClient } from "./graph.mjs";
|
||||
const client = new SharePointListsClient({
|
||||
tenantId: process.env.SP_TENANT_ID,
|
||||
clientId: process.env.SP_CLIENT_ID,
|
||||
clientSecret: process.env.SP_CLIENT_SECRET,
|
||||
});
|
||||
// Expose client.listItems / getItem / createItem / updateItem / deleteItem
|
||||
// as MCP tools. The Graph + auth logic is already done.
|
||||
```
|
||||
|
||||
The client is dependency-free and stateless apart from an in-memory token cache,
|
||||
so it drops straight into an MCP server (`@modelcontextprotocol/sdk`) or any
|
||||
other host.
|
||||
341
.claude/skills/sharepoint-lists/scripts/lib/graph.mjs
Normal file
341
.claude/skills/sharepoint-lists/scripts/lib/graph.mjs
Normal file
@@ -0,0 +1,341 @@
|
||||
// SharePoint Lists core client — Microsoft Graph, app-only (client credentials).
|
||||
//
|
||||
// Zero dependencies. Uses Node's global fetch (Node 18+). This module is the
|
||||
// single source of truth for talking to SharePoint Lists; the CLI (sp.mjs) is a
|
||||
// thin wrapper around it, and a future MCP server can import this same class.
|
||||
//
|
||||
// Auth model: app-only via OAuth2 client credentials. The app registration must
|
||||
// hold an *application* permission such as Sites.Selected (preferred) or
|
||||
// Sites.ReadWrite.All, with admin consent granted. See references/setup.md.
|
||||
|
||||
const LOGIN_HOST = "https://login.microsoftonline.com";
|
||||
const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
|
||||
|
||||
const GUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** Error carrying the Graph status code + structured error body, when present. */
|
||||
export class GraphError extends Error {
|
||||
constructor(message, { status, code, requestId, body } = {}) {
|
||||
super(message);
|
||||
this.name = "GraphError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.requestId = requestId;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
export class SharePointListsClient {
|
||||
/**
|
||||
* @param {object} cfg
|
||||
* @param {string} cfg.tenantId Directory (tenant) ID or domain.
|
||||
* @param {string} cfg.clientId Application (client) ID.
|
||||
* @param {string} cfg.clientSecret Client secret value.
|
||||
* @param {number} [cfg.maxRetries=3] Retries for 429/503 (honors Retry-After).
|
||||
*/
|
||||
constructor({ tenantId, clientId, clientSecret, maxRetries = 3 } = {}) {
|
||||
if (!tenantId || !clientId || !clientSecret) {
|
||||
throw new Error(
|
||||
"Missing credentials: tenantId, clientId, and clientSecret are all required.",
|
||||
);
|
||||
}
|
||||
this.tenantId = tenantId;
|
||||
this.clientId = clientId;
|
||||
this.clientSecret = clientSecret;
|
||||
this.maxRetries = maxRetries;
|
||||
this._token = null; // { accessToken, expiresAt }
|
||||
this._siteCache = new Map(); // siteUrl -> siteId
|
||||
this._listCache = new Map(); // `${siteId}::${nameOrId}` -> list object
|
||||
}
|
||||
|
||||
// ---- Auth ---------------------------------------------------------------
|
||||
|
||||
async getToken() {
|
||||
const now = Date.now();
|
||||
if (this._token && this._token.expiresAt - 60_000 > now) {
|
||||
return this._token.accessToken;
|
||||
}
|
||||
const url = `${LOGIN_HOST}/${encodeURIComponent(this.tenantId)}/oauth2/v2.0/token`;
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "client_credentials",
|
||||
client_id: this.clientId,
|
||||
client_secret: this.clientSecret,
|
||||
scope: "https://graph.microsoft.com/.default",
|
||||
});
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new GraphError(
|
||||
`Token request failed (${res.status}): ${json.error_description || json.error || res.statusText}`,
|
||||
{ status: res.status, code: json.error, body: json },
|
||||
);
|
||||
}
|
||||
this._token = {
|
||||
accessToken: json.access_token,
|
||||
expiresAt: now + (json.expires_in ?? 3600) * 1000,
|
||||
};
|
||||
return this._token.accessToken;
|
||||
}
|
||||
|
||||
// ---- Low-level request --------------------------------------------------
|
||||
|
||||
/**
|
||||
* Make a Graph request. `path` is relative to the v1.0 root (e.g. "/sites/...").
|
||||
* @param {string} method
|
||||
* @param {string} path
|
||||
* @param {object} [opts]
|
||||
* @param {object} [opts.query] Query params; keys starting with "$" are OData.
|
||||
* @param {object} [opts.body] JSON body (will be stringified).
|
||||
* @param {object} [opts.headers] Extra headers (e.g. Prefer).
|
||||
*/
|
||||
async graph(method, path, { query, body, headers } = {}) {
|
||||
const url = new URL(path.startsWith("http") ? path : `${GRAPH_ROOT}${path}`);
|
||||
if (query) {
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (v !== undefined && v !== null && v !== "") url.searchParams.set(k, v);
|
||||
}
|
||||
}
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const token = await this.getToken();
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
...(body ? { "Content-Type": "application/json" } : {}),
|
||||
...headers,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if ((res.status === 429 || res.status === 503) && attempt < this.maxRetries) {
|
||||
const retryAfter = Number(res.headers.get("Retry-After")) || 2 ** attempt;
|
||||
await sleep(retryAfter * 1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (res.status === 204) return null; // No Content (e.g. DELETE)
|
||||
const json = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
const err = json?.error || {};
|
||||
throw new GraphError(
|
||||
`Graph ${method} ${url.pathname} failed (${res.status}): ${err.message || res.statusText}`,
|
||||
{
|
||||
status: res.status,
|
||||
code: err.code,
|
||||
requestId: res.headers.get("request-id"),
|
||||
body: json,
|
||||
},
|
||||
);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Site & list resolution --------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve a site URL (or host:path, or raw site id) to a Graph site id.
|
||||
* Accepts:
|
||||
* - "https://contoso.sharepoint.com/sites/Marketing"
|
||||
* - "contoso.sharepoint.com:/sites/Marketing"
|
||||
* - "contoso.sharepoint.com,<guid>,<guid>" (already a site id) -> returned as-is
|
||||
*/
|
||||
async resolveSiteId(site) {
|
||||
if (!site) throw new Error("A site URL or id is required.");
|
||||
// Already a composite site id (host,guid,guid)?
|
||||
if (site.includes(",")) return site;
|
||||
if (this._siteCache.has(site)) return this._siteCache.get(site);
|
||||
|
||||
let host, relPath;
|
||||
if (site.startsWith("http://") || site.startsWith("https://")) {
|
||||
const u = new URL(site);
|
||||
host = u.host;
|
||||
relPath = u.pathname.replace(/\/$/, "");
|
||||
} else if (site.includes(":")) {
|
||||
[host, relPath] = site.split(":");
|
||||
relPath = (relPath || "").replace(/\/$/, "");
|
||||
} else {
|
||||
host = site; // bare hostname -> root site
|
||||
relPath = "";
|
||||
}
|
||||
|
||||
const path = relPath
|
||||
? `/sites/${host}:${encodeURI(relPath)}`
|
||||
: `/sites/${host}`;
|
||||
const data = await this.graph("GET", path, { query: { $select: "id,displayName,webUrl" } });
|
||||
this._siteCache.set(site, data.id);
|
||||
return data.id;
|
||||
}
|
||||
|
||||
/** List all lists in a site. */
|
||||
async listLists(siteId, { select = "id,name,displayName,webUrl,list" } = {}) {
|
||||
const data = await this.graph("GET", `/sites/${siteId}/lists`, {
|
||||
query: { $select: select },
|
||||
});
|
||||
return data.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a list identifier (GUID, internal name, or display name) to its id.
|
||||
* Caches per (siteId, identifier).
|
||||
*/
|
||||
async resolveListId(siteId, listIdOrName) {
|
||||
if (!listIdOrName) throw new Error("A list id or name is required.");
|
||||
if (GUID_RE.test(listIdOrName)) return listIdOrName;
|
||||
|
||||
const key = `${siteId}::${listIdOrName}`;
|
||||
if (this._listCache.has(key)) return this._listCache.get(key).id;
|
||||
|
||||
const lists = await this.listLists(siteId);
|
||||
const needle = listIdOrName.toLowerCase();
|
||||
const match = lists.find(
|
||||
(l) =>
|
||||
l.name?.toLowerCase() === needle ||
|
||||
l.displayName?.toLowerCase() === needle,
|
||||
);
|
||||
if (!match) {
|
||||
const names = lists.map((l) => l.displayName || l.name).join(", ");
|
||||
throw new Error(
|
||||
`No list named "${listIdOrName}" in this site. Available: ${names || "(none)"}`,
|
||||
);
|
||||
}
|
||||
this._listCache.set(key, match);
|
||||
return match.id;
|
||||
}
|
||||
|
||||
/** Get a single list's metadata. */
|
||||
async getList(siteId, listIdOrName) {
|
||||
const listId = await this.resolveListId(siteId, listIdOrName);
|
||||
return this.graph("GET", `/sites/${siteId}/lists/${listId}`);
|
||||
}
|
||||
|
||||
/** Get column definitions (internal names, types) for a list. */
|
||||
async getColumns(siteId, listIdOrName) {
|
||||
const listId = await this.resolveListId(siteId, listIdOrName);
|
||||
const data = await this.graph("GET", `/sites/${siteId}/lists/${listId}/columns`);
|
||||
return data.value;
|
||||
}
|
||||
|
||||
// ---- Items: read --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* List items with their field values.
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.filter] OData $filter, e.g. "fields/Status eq 'Open'".
|
||||
* @param {string} [opts.select] Fields to return, e.g. "Title,Status".
|
||||
* @param {string} [opts.orderby] e.g. "fields/Created desc".
|
||||
* @param {number} [opts.top] Page size / max when not fetching all.
|
||||
* @param {boolean}[opts.all] Follow @odata.nextLink to fetch every page.
|
||||
*/
|
||||
async listItems(siteId, listIdOrName, opts = {}) {
|
||||
const listId = await this.resolveListId(siteId, listIdOrName);
|
||||
const { filter, select, orderby, top, all = false } = opts;
|
||||
|
||||
// Filtering/sorting on non-indexed columns requires this Prefer header.
|
||||
const headers = filter || orderby
|
||||
? { Prefer: "HonorNonIndexedQueriesWarningMayFailRandomly" }
|
||||
: undefined;
|
||||
|
||||
const expand = select
|
||||
? `fields($select=${select})`
|
||||
: "fields";
|
||||
const query = {
|
||||
expand,
|
||||
$filter: filter,
|
||||
$orderby: orderby,
|
||||
$top: top,
|
||||
};
|
||||
|
||||
let data = await this.graph("GET", `/sites/${siteId}/lists/${listId}/items`, {
|
||||
query,
|
||||
headers,
|
||||
});
|
||||
const items = data.value.map(simplifyItem);
|
||||
if (!all) return items;
|
||||
|
||||
while (data["@odata.nextLink"]) {
|
||||
data = await this.graph("GET", data["@odata.nextLink"], { headers });
|
||||
items.push(...data.value.map(simplifyItem));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Get a single item by id, including its fields. */
|
||||
async getItem(siteId, listIdOrName, itemId) {
|
||||
const listId = await this.resolveListId(siteId, listIdOrName);
|
||||
const data = await this.graph(
|
||||
"GET",
|
||||
`/sites/${siteId}/lists/${listId}/items/${itemId}`,
|
||||
{ query: { expand: "fields" } },
|
||||
);
|
||||
return simplifyItem(data);
|
||||
}
|
||||
|
||||
// ---- Items: write -------------------------------------------------------
|
||||
|
||||
/** Create an item. `fields` is a map of internal column name -> value. */
|
||||
async createItem(siteId, listIdOrName, fields) {
|
||||
const listId = await this.resolveListId(siteId, listIdOrName);
|
||||
const data = await this.graph(
|
||||
"POST",
|
||||
`/sites/${siteId}/lists/${listId}/items`,
|
||||
{ body: { fields } },
|
||||
);
|
||||
return simplifyItem(data);
|
||||
}
|
||||
|
||||
/** Update an item's fields (partial; only provided fields change). */
|
||||
async updateItem(siteId, listIdOrName, itemId, fields) {
|
||||
const listId = await this.resolveListId(siteId, listIdOrName);
|
||||
const data = await this.graph(
|
||||
"PATCH",
|
||||
`/sites/${siteId}/lists/${listId}/items/${itemId}/fields`,
|
||||
{ body: fields },
|
||||
);
|
||||
return { id: String(itemId), fields: data };
|
||||
}
|
||||
|
||||
/** Delete an item by id. Returns true on success. */
|
||||
async deleteItem(siteId, listIdOrName, itemId) {
|
||||
const listId = await this.resolveListId(siteId, listIdOrName);
|
||||
await this.graph(
|
||||
"DELETE",
|
||||
`/sites/${siteId}/lists/${listId}/items/${itemId}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- Convenience --------------------------------------------------------
|
||||
|
||||
/** Verify credentials + connectivity. Returns the resolved site (if given). */
|
||||
async test(site) {
|
||||
await this.getToken();
|
||||
if (!site) return { ok: true, token: "acquired" };
|
||||
const siteId = await this.resolveSiteId(site);
|
||||
const lists = await this.listLists(siteId);
|
||||
return {
|
||||
ok: true,
|
||||
siteId,
|
||||
listCount: lists.length,
|
||||
lists: lists.map((l) => l.displayName || l.name),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Flatten the Graph item shape into { id, fields, webUrl, ...meta }. */
|
||||
function simplifyItem(item) {
|
||||
if (!item) return item;
|
||||
const { id, webUrl, createdDateTime, lastModifiedDateTime, fields } = item;
|
||||
return { id, webUrl, createdDateTime, lastModifiedDateTime, fields: fields ?? {} };
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
export default SharePointListsClient;
|
||||
197
.claude/skills/sharepoint-lists/scripts/sp.mjs
Normal file
197
.claude/skills/sharepoint-lists/scripts/sp.mjs
Normal file
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env node
|
||||
// CLI wrapper around SharePointListsClient. Prints JSON to stdout; errors to
|
||||
// stderr with a non-zero exit so callers can detect failure reliably.
|
||||
//
|
||||
// Credentials are read from the environment (or a .env file in the CWD or this
|
||||
// skill's directory). Required:
|
||||
// SP_TENANT_ID (or AZURE_TENANT_ID)
|
||||
// SP_CLIENT_ID (or AZURE_CLIENT_ID)
|
||||
// SP_CLIENT_SECRET (or AZURE_CLIENT_SECRET)
|
||||
// Optional default so you can omit --site on every call:
|
||||
// SP_SITE_URL e.g. https://contoso.sharepoint.com/sites/Marketing
|
||||
//
|
||||
// Usage:
|
||||
// node sp.mjs test [--site URL]
|
||||
// node sp.mjs lists --site URL
|
||||
// node sp.mjs columns --site URL --list NAME_OR_ID
|
||||
// node sp.mjs items --site URL --list NAME_OR_ID [--filter ODATA] [--select F1,F2] [--orderby "fields/Created desc"] [--top N] [--all]
|
||||
// node sp.mjs get --site URL --list NAME_OR_ID --id ITEM_ID
|
||||
// node sp.mjs create --site URL --list NAME_OR_ID --fields '{"Title":"Hi"}'
|
||||
// node sp.mjs update --site URL --list NAME_OR_ID --id ITEM_ID --fields '{"Status":"Done"}'
|
||||
// node sp.mjs delete --site URL --list NAME_OR_ID --id ITEM_ID
|
||||
//
|
||||
// --fields accepts inline JSON, @path/to/file.json, or "-" to read JSON from stdin.
|
||||
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { SharePointListsClient, GraphError } from "./lib/graph.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
main().catch((err) => {
|
||||
const payload = {
|
||||
ok: false,
|
||||
error: err.message,
|
||||
...(err instanceof GraphError
|
||||
? { status: err.status, code: err.code, requestId: err.requestId }
|
||||
: {}),
|
||||
};
|
||||
process.stderr.write(JSON.stringify(payload, null, 2) + "\n");
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
async function main() {
|
||||
loadDotEnv();
|
||||
const [, , command, ...rest] = process.argv;
|
||||
const flags = parseFlags(rest);
|
||||
|
||||
if (!command || command === "help" || flags.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new SharePointListsClient({
|
||||
tenantId: env("SP_TENANT_ID", "AZURE_TENANT_ID"),
|
||||
clientId: env("SP_CLIENT_ID", "AZURE_CLIENT_ID"),
|
||||
clientSecret: env("SP_CLIENT_SECRET", "AZURE_CLIENT_SECRET"),
|
||||
});
|
||||
|
||||
const site = flags.site || process.env.SP_SITE_URL;
|
||||
const needSite = () => {
|
||||
if (!site) throw new Error("Missing --site URL (or set SP_SITE_URL).");
|
||||
return client.resolveSiteId(site);
|
||||
};
|
||||
const needList = () => {
|
||||
if (!flags.list) throw new Error("Missing --list NAME_OR_ID.");
|
||||
return flags.list;
|
||||
};
|
||||
const needId = () => {
|
||||
if (!flags.id) throw new Error("Missing --id ITEM_ID.");
|
||||
return flags.id;
|
||||
};
|
||||
|
||||
let result;
|
||||
switch (command) {
|
||||
case "test":
|
||||
result = await client.test(site);
|
||||
break;
|
||||
case "lists":
|
||||
result = await client.listLists(await needSite());
|
||||
break;
|
||||
case "columns":
|
||||
result = await client.getColumns(await needSite(), needList());
|
||||
break;
|
||||
case "items":
|
||||
result = await client.listItems(await needSite(), needList(), {
|
||||
filter: flags.filter,
|
||||
select: flags.select,
|
||||
orderby: flags.orderby,
|
||||
top: flags.top ? Number(flags.top) : undefined,
|
||||
all: Boolean(flags.all),
|
||||
});
|
||||
break;
|
||||
case "get":
|
||||
result = await client.getItem(await needSite(), needList(), needId());
|
||||
break;
|
||||
case "create":
|
||||
result = await client.createItem(await needSite(), needList(), readFields(flags));
|
||||
break;
|
||||
case "update":
|
||||
result = await client.updateItem(await needSite(), needList(), needId(), readFields(flags));
|
||||
break;
|
||||
case "delete":
|
||||
result = await client.deleteItem(await needSite(), needList(), needId());
|
||||
result = { ok: true, deleted: needId() };
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown command "${command}". Run "node sp.mjs help".`);
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
||||
}
|
||||
|
||||
// ---- helpers --------------------------------------------------------------
|
||||
|
||||
function env(...names) {
|
||||
for (const n of names) if (process.env[n]) return process.env[n];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseFlags(args) {
|
||||
const flags = {};
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (!a.startsWith("--")) continue;
|
||||
const key = a.slice(2);
|
||||
const next = args[i + 1];
|
||||
if (next === undefined || next.startsWith("--")) {
|
||||
flags[key] = true; // boolean flag (e.g. --all)
|
||||
} else {
|
||||
flags[key] = next;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
function readFields(flags) {
|
||||
const raw = flags.fields;
|
||||
if (raw === undefined || raw === true) {
|
||||
throw new Error('Missing --fields \'{"Col":"value"}\' (or @file.json, or - for stdin).');
|
||||
}
|
||||
let text = raw;
|
||||
if (raw === "-") text = readFileSync(0, "utf8");
|
||||
else if (raw.startsWith("@")) text = readFileSync(raw.slice(1), "utf8");
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (e) {
|
||||
throw new Error(`--fields is not valid JSON: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal .env loader (no dependency). Looks in CWD then this skill's dir.
|
||||
function loadDotEnv() {
|
||||
for (const dir of [process.cwd(), __dirname, join(__dirname, "..")]) {
|
||||
const file = join(dir, ".env");
|
||||
if (!existsSync(file)) continue;
|
||||
for (const line of readFileSync(file, "utf8").split("\n")) {
|
||||
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i);
|
||||
if (!m) continue;
|
||||
const key = m[1];
|
||||
let val = m[2].trim();
|
||||
if (
|
||||
(val.startsWith('"') && val.endsWith('"')) ||
|
||||
(val.startsWith("'") && val.endsWith("'"))
|
||||
) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
if (process.env[key] === undefined) process.env[key] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
process.stdout.write(
|
||||
`sharepoint-lists CLI — Microsoft Graph, app-only auth
|
||||
|
||||
Commands:
|
||||
test [--site URL] Verify auth (+ list lists if --site)
|
||||
lists --site URL List all lists in a site
|
||||
columns --site URL --list NAME_OR_ID Show column internal names + types
|
||||
items --site URL --list NAME_OR_ID [filters] Query list items
|
||||
get --site URL --list NAME_OR_ID --id ID Get one item
|
||||
create --site URL --list NAME_OR_ID --fields J Create an item
|
||||
update --site URL --list NAME_OR_ID --id ID --fields J Update an item
|
||||
delete --site URL --list NAME_OR_ID --id ID Delete an item
|
||||
|
||||
items filters:
|
||||
--filter "fields/Status eq 'Open'" --select Title,Status
|
||||
--orderby "fields/Created desc" --top 50 --all
|
||||
|
||||
--fields accepts inline JSON, @file.json, or - (stdin).
|
||||
Credentials come from env / .env: SP_TENANT_ID, SP_CLIENT_ID, SP_CLIENT_SECRET.
|
||||
Optional: SP_SITE_URL to default --site.
|
||||
`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user