Files
Claude-SharepointLists/.claude/skills/sharepoint-lists/scripts/sp.mjs
ang3l12 65fe53710a Add person resolver: email → LookupId for writing person columns
Person/group columns store a numeric LookupId into each site's hidden User Information List, and SPO's ensureUser needs certificate auth that client secrets can't provide — so resolve by querying the hidden list directly via Graph (item id IS the LookupId). New resolvePersonByEmail() in graph.mjs (server-side EMail/UserName filter with a paged case-insensitive scan fallback), a 'person' CLI command, and a sharepoint_resolve_person MCP tool (9 tools now). Verified live against known ground truth (LookupIds 53 and 136) plus the not-found path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:02:25 -06:00

210 lines
7.3 KiB
JavaScript

#!/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, secretExpiryStatus } 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"),
});
// Nag (on stderr, so stdout stays clean JSON) when the client secret is
// close to its self-reported expiry — see secretExpiryStatus in graph.mjs.
const expiry = secretExpiryStatus(process.env.SP_SECRET_EXPIRES);
if (expiry?.message) process.stderr.write(`${expiry.message}\n`);
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);
if (expiry) result.secretExpiry = expiry;
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 "person": {
if (!flags.email) throw new Error("Missing --email user@domain.com");
result = await client.resolvePersonByEmail(await needSite(), flags.email);
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
person --site URL --email USER@DOMAIN Resolve a person to the LookupId used by person columns
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.
`,
);
}