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:
ang3l12
2026-06-16 11:12:50 -06:00
commit fd957177b9
9 changed files with 997 additions and 0 deletions

View 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

View File

@@ -0,0 +1,3 @@
# Never commit real credentials.
.env
*.env.local

View 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).

View 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`.

View 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. 612 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 &lt;tenant&gt;** 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.

View 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;

View 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.
`,
);
}