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:
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