Initial commit: Microsoft To Do for Claude (skill + .mcpb extension)
Claude Code skill (CLI over Microsoft Graph, delegated auth-code + PKCE) and a Claude Desktop .mcpb extension sharing one dependency-free core client. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
642
.claude/skills/ms-todo/scripts/lib/graph.mjs
Normal file
642
.claude/skills/ms-todo/scripts/lib/graph.mjs
Normal file
@@ -0,0 +1,642 @@
|
||||
// Microsoft To Do core client — Microsoft Graph, DELEGATED (auth-code + PKCE).
|
||||
//
|
||||
// Zero dependencies. Uses Node's global fetch (Node 18+). This module is the
|
||||
// single source of truth for talking to Microsoft To Do; the CLI (todo.mjs) is a
|
||||
// thin wrapper around it, and a future MCP server can import this same class.
|
||||
//
|
||||
// Auth model — IMPORTANT: Microsoft To Do does NOT support app-only access.
|
||||
// The Graph /me/todo endpoints only accept *delegated* permissions
|
||||
// (Tasks.ReadWrite), so a user must sign in. We use the OAuth2 *authorization
|
||||
// code* flow with PKCE and a loopback (http://localhost) redirect: `login` opens
|
||||
// the system browser, the user signs in, and the refresh token is cached to disk
|
||||
// so subsequent runs refresh silently — no client secret, works with MFA.
|
||||
//
|
||||
// Why not device-code? Many tenants block the device-code flow via Conditional
|
||||
// Access (AADSTS53003), because it's headless. The browser-based auth-code flow
|
||||
// runs in the device's real browser session, so it satisfies far more CA
|
||||
// policies. (If a policy still requires a *compliant/managed device*, the Mac
|
||||
// must be registered/enrolled first — no auth flow can bypass that.)
|
||||
//
|
||||
// The app registration must register a public-client redirect URI of
|
||||
// http://localhost ("Mobile and desktop applications" platform) and hold the
|
||||
// delegated Tasks.ReadWrite (+ offline_access, openid, profile, User.Read)
|
||||
// permission. See references/setup.md.
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, rmSync, chmodSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import { randomBytes, createHash } from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const LOGIN_HOST = "https://login.microsoftonline.com";
|
||||
const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
|
||||
|
||||
// offline_access -> refresh token; openid/profile -> id_token (who signed in);
|
||||
// User.Read -> identify the user in `test`; Tasks.ReadWrite -> full To Do CRUD.
|
||||
const SCOPES = "offline_access openid profile User.Read Tasks.ReadWrite";
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when there is no cached login (or the refresh token is dead). */
|
||||
export class AuthRequiredError extends Error {
|
||||
constructor(message = 'Not signed in. Run "node todo.mjs login" first.') {
|
||||
super(message);
|
||||
this.name = "AuthRequiredError";
|
||||
}
|
||||
}
|
||||
|
||||
export class MSTodoClient {
|
||||
/**
|
||||
* @param {object} cfg
|
||||
* @param {string} cfg.clientId Application (client) ID of a PUBLIC client app.
|
||||
* @param {string} [cfg.tenantId="common"] Tenant id / "common" / "organizations" / "consumers".
|
||||
* @param {string} cfg.tokenCachePath Absolute path to the JSON token cache file.
|
||||
* @param {number} [cfg.redirectPort=0] Loopback port for the auth-code redirect.
|
||||
* 0 = let the OS pick (works when http://localhost is registered, since Entra
|
||||
* ignores the port for loopback). Pin it only if your tenant requires an exact
|
||||
* redirect URI (then register http://localhost:<port>).
|
||||
* @param {number} [cfg.maxRetries=3] Retries for 429/503 (honors Retry-After).
|
||||
*/
|
||||
constructor({ clientId, tenantId = "common", tokenCachePath, redirectPort = 0, maxRetries = 3 } = {}) {
|
||||
if (!clientId) {
|
||||
throw new Error("Missing clientId (set TODO_CLIENT_ID). See references/setup.md.");
|
||||
}
|
||||
if (!tokenCachePath) {
|
||||
throw new Error("Missing tokenCachePath.");
|
||||
}
|
||||
this.clientId = clientId;
|
||||
this.tenantId = tenantId || "common";
|
||||
this.tokenCachePath = tokenCachePath;
|
||||
this.redirectPort = redirectPort;
|
||||
this.maxRetries = maxRetries;
|
||||
this._cache = null; // lazy-loaded { accessToken, expiresAt, refreshToken, ... }
|
||||
this._listCache = new Map(); // nameOrId(lower) -> list object
|
||||
}
|
||||
|
||||
// ---- Token cache (on disk) ---------------------------------------------
|
||||
|
||||
_loadCache() {
|
||||
if (this._cache) return this._cache;
|
||||
if (!existsSync(this.tokenCachePath)) return null;
|
||||
try {
|
||||
this._cache = JSON.parse(readFileSync(this.tokenCachePath, "utf8"));
|
||||
} catch {
|
||||
this._cache = null;
|
||||
}
|
||||
return this._cache;
|
||||
}
|
||||
|
||||
_saveCache(tokens) {
|
||||
const cache = {
|
||||
clientId: this.clientId,
|
||||
tenantId: this.tenantId,
|
||||
accessToken: tokens.access_token,
|
||||
expiresAt: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
||||
refreshToken: tokens.refresh_token ?? this._cache?.refreshToken,
|
||||
scope: tokens.scope ?? SCOPES,
|
||||
account: tokens.account ?? this._cache?.account,
|
||||
};
|
||||
writeFileSync(this.tokenCachePath, JSON.stringify(cache, null, 2));
|
||||
try {
|
||||
chmodSync(this.tokenCachePath, 0o600); // best-effort; refresh token is sensitive
|
||||
} catch {
|
||||
/* ignore (e.g. on filesystems without POSIX modes) */
|
||||
}
|
||||
this._cache = cache;
|
||||
return cache;
|
||||
}
|
||||
|
||||
/** Remove the cached login. Returns true if a cache file existed. */
|
||||
logout() {
|
||||
this._cache = null;
|
||||
if (existsSync(this.tokenCachePath)) {
|
||||
rmSync(this.tokenCachePath);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
isLoggedIn() {
|
||||
const c = this._loadCache();
|
||||
return Boolean(c?.refreshToken);
|
||||
}
|
||||
|
||||
// ---- Auth ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Interactive sign-in via the authorization-code + PKCE flow. Spins up a
|
||||
* temporary loopback HTTP server, opens the system browser to the Entra
|
||||
* authorize page, captures the redirect, exchanges the code for tokens, and
|
||||
* persists them to the cache file. Returns the cached account.
|
||||
*
|
||||
* @param {(info:{authorizeUrl:string,redirectUri:string})=>void} [onPrompt]
|
||||
* Called with the URL being opened (so the CLI can print it as a fallback).
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.timeoutMs=300000] How long to wait for the redirect.
|
||||
*/
|
||||
async login(onPrompt, { timeoutMs = 300_000 } = {}) {
|
||||
const verifier = base64url(randomBytes(32));
|
||||
const challenge = base64url(createHash("sha256").update(verifier).digest());
|
||||
const state = base64url(randomBytes(16));
|
||||
|
||||
// Start the loopback server first so we know the actual port for redirect_uri.
|
||||
const { server, port, waitForCode } = await startRedirectServer(
|
||||
state,
|
||||
timeoutMs,
|
||||
this.redirectPort,
|
||||
);
|
||||
// 127.0.0.1 (not "localhost") so the bind address and the browser's target
|
||||
// always agree, even on IPv6-first hosts. Register http://localhost AND
|
||||
// http://127.0.0.1 in the app so either works. Entra ignores the port for
|
||||
// loopback redirects, so the OS-assigned port needs no registration.
|
||||
const redirectUri = `http://127.0.0.1:${port}`;
|
||||
|
||||
const authorizeUrl =
|
||||
`${LOGIN_HOST}/${encodeURIComponent(this.tenantId)}/oauth2/v2.0/authorize?` +
|
||||
new URLSearchParams({
|
||||
client_id: this.clientId,
|
||||
response_type: "code",
|
||||
redirect_uri: redirectUri,
|
||||
response_mode: "query",
|
||||
scope: SCOPES,
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
prompt: "select_account",
|
||||
});
|
||||
|
||||
if (typeof onPrompt === "function") onPrompt({ authorizeUrl, redirectUri });
|
||||
openBrowser(authorizeUrl);
|
||||
|
||||
let code;
|
||||
try {
|
||||
code = await waitForCode;
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
|
||||
const tokRes = await fetch(
|
||||
`${LOGIN_HOST}/${encodeURIComponent(this.tenantId)}/oauth2/v2.0/token`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: this.clientId,
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: verifier,
|
||||
scope: SCOPES,
|
||||
}),
|
||||
},
|
||||
);
|
||||
const tok = await tokRes.json().catch(() => ({}));
|
||||
if (!tokRes.ok) {
|
||||
throw new GraphError(
|
||||
`Token exchange failed (${tokRes.status}): ${tok.error_description || tok.error || tokRes.statusText}`,
|
||||
{ status: tokRes.status, code: tok.error, body: tok },
|
||||
);
|
||||
}
|
||||
this._saveCache({ ...tok, account: accountFromIdToken(tok.id_token) });
|
||||
return this._cache.account ?? { ok: true };
|
||||
}
|
||||
|
||||
/** Get a valid access token, refreshing with the cached refresh token if needed. */
|
||||
async getToken() {
|
||||
const cache = this._loadCache();
|
||||
if (!cache?.refreshToken) throw new AuthRequiredError();
|
||||
if (cache.accessToken && cache.expiresAt - 60_000 > Date.now()) {
|
||||
return cache.accessToken;
|
||||
}
|
||||
const res = await fetch(
|
||||
`${LOGIN_HOST}/${encodeURIComponent(this.tenantId)}/oauth2/v2.0/token`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
client_id: this.clientId,
|
||||
refresh_token: cache.refreshToken,
|
||||
scope: SCOPES,
|
||||
}),
|
||||
},
|
||||
);
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
// invalid_grant => the refresh token is revoked/expired; force re-login.
|
||||
if (json.error === "invalid_grant") {
|
||||
throw new AuthRequiredError(
|
||||
'Saved sign-in expired or was revoked. Run "node todo.mjs login" again.',
|
||||
);
|
||||
}
|
||||
throw new GraphError(
|
||||
`Token refresh failed (${res.status}): ${json.error_description || json.error || res.statusText}`,
|
||||
{ status: res.status, code: json.error, body: json },
|
||||
);
|
||||
}
|
||||
this._saveCache(json);
|
||||
return this._cache.accessToken;
|
||||
}
|
||||
|
||||
// ---- Low-level request --------------------------------------------------
|
||||
|
||||
/**
|
||||
* Make a Graph request. `path` is relative to the v1.0 root (e.g. "/me/todo/lists").
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
|
||||
/** Identify the signed-in user. */
|
||||
async me() {
|
||||
return this.graph("GET", "/me", {
|
||||
query: { $select: "id,displayName,userPrincipalName,mail" },
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Task lists ---------------------------------------------------------
|
||||
|
||||
/** List all To Do task lists. */
|
||||
async listTaskLists() {
|
||||
const data = await this.graph("GET", "/me/todo/lists");
|
||||
return data.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a list identifier (opaque list id or display name) to its id.
|
||||
* To Do list ids are long opaque strings, not GUIDs, so we always check
|
||||
* display names too. Caches per identifier.
|
||||
*/
|
||||
async resolveListId(listIdOrName) {
|
||||
if (!listIdOrName) throw new Error("A list id or name is required.");
|
||||
const key = listIdOrName.toLowerCase();
|
||||
if (this._listCache.has(key)) return this._listCache.get(key).id;
|
||||
|
||||
const lists = await this.listTaskLists();
|
||||
// Exact id match first (ids are case-sensitive opaque strings).
|
||||
let match = lists.find((l) => l.id === listIdOrName);
|
||||
if (!match) {
|
||||
match = lists.find((l) => l.displayName?.toLowerCase() === key);
|
||||
}
|
||||
if (!match) {
|
||||
const names = lists.map((l) => l.displayName).join(", ");
|
||||
throw new Error(
|
||||
`No task list named "${listIdOrName}". Available: ${names || "(none)"}`,
|
||||
);
|
||||
}
|
||||
this._listCache.set(key, match);
|
||||
return match.id;
|
||||
}
|
||||
|
||||
/** Create a task list. */
|
||||
async createTaskList(displayName) {
|
||||
if (!displayName) throw new Error("displayName is required.");
|
||||
this._listCache.clear();
|
||||
return this.graph("POST", "/me/todo/lists", { body: { displayName } });
|
||||
}
|
||||
|
||||
/** Rename a task list. */
|
||||
async updateTaskList(listIdOrName, displayName) {
|
||||
const listId = await this.resolveListId(listIdOrName);
|
||||
this._listCache.clear();
|
||||
return this.graph("PATCH", `/me/todo/lists/${listId}`, { body: { displayName } });
|
||||
}
|
||||
|
||||
/** Delete a task list (and all its tasks). */
|
||||
async deleteTaskList(listIdOrName) {
|
||||
const listId = await this.resolveListId(listIdOrName);
|
||||
await this.graph("DELETE", `/me/todo/lists/${listId}`);
|
||||
this._listCache.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- Tasks: read --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* List tasks in a list.
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.filter] OData $filter, e.g. "status ne 'completed'".
|
||||
* @param {string} [opts.select] Comma list of properties, e.g. "title,status,dueDateTime".
|
||||
* @param {string} [opts.orderby] e.g. "dueDateTime/dateTime asc".
|
||||
* @param {number} [opts.top] Page size / max when not fetching all.
|
||||
* @param {boolean}[opts.all] Follow @odata.nextLink to fetch every page.
|
||||
*/
|
||||
async listTasks(listIdOrName, opts = {}) {
|
||||
const listId = await this.resolveListId(listIdOrName);
|
||||
const { filter, select, orderby, top, all = false } = opts;
|
||||
const query = { $filter: filter, $select: select, $orderby: orderby, $top: top };
|
||||
|
||||
let data = await this.graph("GET", `/me/todo/lists/${listId}/tasks`, { query });
|
||||
const tasks = data.value.map(simplifyTask);
|
||||
if (!all) return tasks;
|
||||
|
||||
while (data["@odata.nextLink"]) {
|
||||
data = await this.graph("GET", data["@odata.nextLink"]);
|
||||
tasks.push(...data.value.map(simplifyTask));
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/** Get a single task by id. */
|
||||
async getTask(listIdOrName, taskId) {
|
||||
const listId = await this.resolveListId(listIdOrName);
|
||||
const data = await this.graph("GET", `/me/todo/lists/${listId}/tasks/${taskId}`);
|
||||
return simplifyTask(data);
|
||||
}
|
||||
|
||||
// ---- Tasks: write -------------------------------------------------------
|
||||
|
||||
/** Create a task. `fields` is a map of todoTask property -> value (title required). */
|
||||
async createTask(listIdOrName, fields) {
|
||||
const listId = await this.resolveListId(listIdOrName);
|
||||
if (!fields || !fields.title) throw new Error("A task `title` is required.");
|
||||
const data = await this.graph("POST", `/me/todo/lists/${listId}/tasks`, { body: fields });
|
||||
return simplifyTask(data);
|
||||
}
|
||||
|
||||
/** Update a task (partial; only provided properties change). */
|
||||
async updateTask(listIdOrName, taskId, fields) {
|
||||
const listId = await this.resolveListId(listIdOrName);
|
||||
const data = await this.graph("PATCH", `/me/todo/lists/${listId}/tasks/${taskId}`, {
|
||||
body: fields,
|
||||
});
|
||||
return simplifyTask(data);
|
||||
}
|
||||
|
||||
/** Mark a task completed (convenience over updateTask). */
|
||||
async completeTask(listIdOrName, taskId) {
|
||||
return this.updateTask(listIdOrName, taskId, { status: "completed" });
|
||||
}
|
||||
|
||||
/** Delete a task by id. */
|
||||
async deleteTask(listIdOrName, taskId) {
|
||||
const listId = await this.resolveListId(listIdOrName);
|
||||
await this.graph("DELETE", `/me/todo/lists/${listId}/tasks/${taskId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- Checklist items (subtasks) ----------------------------------------
|
||||
|
||||
async listChecklistItems(listIdOrName, taskId) {
|
||||
const listId = await this.resolveListId(listIdOrName);
|
||||
const data = await this.graph(
|
||||
"GET",
|
||||
`/me/todo/lists/${listId}/tasks/${taskId}/checklistItems`,
|
||||
);
|
||||
return data.value;
|
||||
}
|
||||
|
||||
async createChecklistItem(listIdOrName, taskId, displayName) {
|
||||
if (!displayName) throw new Error("displayName is required.");
|
||||
const listId = await this.resolveListId(listIdOrName);
|
||||
return this.graph("POST", `/me/todo/lists/${listId}/tasks/${taskId}/checklistItems`, {
|
||||
body: { displayName },
|
||||
});
|
||||
}
|
||||
|
||||
/** Update a checklist item: rename and/or (un)check it. */
|
||||
async updateChecklistItem(listIdOrName, taskId, itemId, { displayName, isChecked } = {}) {
|
||||
const listId = await this.resolveListId(listIdOrName);
|
||||
const body = {};
|
||||
if (displayName !== undefined) body.displayName = displayName;
|
||||
if (isChecked !== undefined) body.isChecked = isChecked;
|
||||
return this.graph(
|
||||
"PATCH",
|
||||
`/me/todo/lists/${listId}/tasks/${taskId}/checklistItems/${itemId}`,
|
||||
{ body },
|
||||
);
|
||||
}
|
||||
|
||||
async deleteChecklistItem(listIdOrName, taskId, itemId) {
|
||||
const listId = await this.resolveListId(listIdOrName);
|
||||
await this.graph(
|
||||
"DELETE",
|
||||
`/me/todo/lists/${listId}/tasks/${taskId}/checklistItems/${itemId}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- Convenience --------------------------------------------------------
|
||||
|
||||
/** Verify the cached login works. Returns the signed-in user + list count. */
|
||||
async test() {
|
||||
const me = await this.me();
|
||||
const lists = await this.listTaskLists();
|
||||
return {
|
||||
ok: true,
|
||||
signedInAs: me.userPrincipalName || me.mail || me.displayName,
|
||||
displayName: me.displayName,
|
||||
listCount: lists.length,
|
||||
lists: lists.map((l) => l.displayName),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers --------------------------------------------------------------
|
||||
|
||||
/** Flatten a todoTask into a compact, useful shape. */
|
||||
function simplifyTask(t) {
|
||||
if (!t) return t;
|
||||
return {
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
status: t.status,
|
||||
importance: t.importance,
|
||||
isReminderOn: t.isReminderOn,
|
||||
dueDateTime: t.dueDateTime,
|
||||
startDateTime: t.startDateTime,
|
||||
reminderDateTime: t.reminderDateTime,
|
||||
completedDateTime: t.completedDateTime,
|
||||
categories: t.categories,
|
||||
body: t.body?.content ? t.body : undefined,
|
||||
recurrence: t.recurrence,
|
||||
hasAttachments: t.hasAttachments,
|
||||
checklistItems: t.checklistItems,
|
||||
createdDateTime: t.createdDateTime,
|
||||
lastModifiedDateTime: t.lastModifiedDateTime,
|
||||
};
|
||||
}
|
||||
|
||||
/** Best-effort decode of the id_token payload to record which account signed in. */
|
||||
function accountFromIdToken(idToken) {
|
||||
if (!idToken || typeof idToken !== "string") return undefined;
|
||||
const parts = idToken.split(".");
|
||||
if (parts.length < 2) return undefined;
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
||||
return {
|
||||
username: payload.preferred_username || payload.upn || payload.email,
|
||||
name: payload.name,
|
||||
tid: payload.tid,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Graph dateTimeTimeZone object from a friendly date string.
|
||||
* Accepts "2026-07-01", "2026-07-01T15:30", or a full ISO string. A bare date
|
||||
* defaults to midnight. Returns { dateTime, timeZone }.
|
||||
*/
|
||||
export function toDateTimeTimeZone(input, timeZone = "UTC") {
|
||||
if (!input) return undefined;
|
||||
let dateTime = String(input).trim();
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(dateTime)) dateTime += "T00:00:00";
|
||||
// Strip a trailing Z — Graph carries the zone in the timeZone field instead.
|
||||
dateTime = dateTime.replace(/Z$/, "");
|
||||
return { dateTime, timeZone };
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
/** URL-safe base64 with no padding (for PKCE verifier/challenge/state). */
|
||||
function base64url(buf) {
|
||||
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a loopback HTTP server that captures the OAuth redirect. Resolves with
|
||||
* `{ server, port, waitForCode }`, where `waitForCode` is a promise for the
|
||||
* authorization code (rejects on error param, state mismatch, or timeout).
|
||||
*/
|
||||
function startRedirectServer(expectedState, timeoutMs, listenPort = 0) {
|
||||
return new Promise((resolveStart, rejectStart) => {
|
||||
let settle;
|
||||
const waitForCode = new Promise((resolve, reject) => {
|
||||
settle = { resolve, reject };
|
||||
});
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
if (url.pathname === "/favicon.ico") {
|
||||
res.writeHead(204).end();
|
||||
return;
|
||||
}
|
||||
const error = url.searchParams.get("error");
|
||||
const errorDesc = url.searchParams.get("error_description");
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
|
||||
const reply = (title, msg) => {
|
||||
res.writeHead(200, { "Content-Type": "text/html" });
|
||||
res.end(
|
||||
`<!doctype html><meta charset="utf-8"><title>${title}</title>` +
|
||||
`<body style="font:16px system-ui;margin:3rem"><h2>${title}</h2><p>${msg}</p>` +
|
||||
`<p>You can close this tab and return to the terminal.</p></body>`,
|
||||
);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
reply("Sign-in failed", escapeHtml(errorDesc || error));
|
||||
settle.reject(
|
||||
new GraphError(`Sign-in was blocked or cancelled: ${errorDesc || error}`, {
|
||||
status: 400,
|
||||
code: error,
|
||||
}),
|
||||
);
|
||||
} else if (!code) {
|
||||
reply("Waiting…", "No authorization code in the request.");
|
||||
} else if (state !== expectedState) {
|
||||
reply("Sign-in failed", "State mismatch — possible CSRF. Try again.");
|
||||
settle.reject(new Error("OAuth state mismatch; aborting sign-in."));
|
||||
} else {
|
||||
reply("Signed in ✓", "Microsoft To Do is now connected.");
|
||||
settle.resolve(code);
|
||||
}
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
settle.reject(new Error("Timed out waiting for browser sign-in."));
|
||||
server.close();
|
||||
}, timeoutMs);
|
||||
// Clear the timer when the code promise settles, without creating an
|
||||
// unhandled-rejection branch (login() is the one that handles the rejection).
|
||||
waitForCode.finally(() => clearTimeout(timer)).catch(() => {});
|
||||
|
||||
server.on("error", (e) => rejectStart(e));
|
||||
server.listen(listenPort, "127.0.0.1", () => {
|
||||
resolveStart({ server, port: server.address().port, waitForCode });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Best-effort open of a URL in the system browser (macOS / Windows / Linux). */
|
||||
function openBrowser(url) {
|
||||
const cmd =
|
||||
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
||||
try {
|
||||
const child = spawn(cmd, process.platform === "win32" ? ["", url] : [url], {
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
child.on("error", () => {}); // ignore; the CLI also prints the URL as a fallback
|
||||
child.unref();
|
||||
} catch {
|
||||
/* ignore — user can open the printed URL manually */
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
||||
}
|
||||
|
||||
export default MSTodoClient;
|
||||
306
.claude/skills/ms-todo/scripts/todo.mjs
Normal file
306
.claude/skills/ms-todo/scripts/todo.mjs
Normal file
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env node
|
||||
// CLI wrapper around MSTodoClient. Prints JSON to stdout; errors to stderr with a
|
||||
// non-zero exit so callers can detect failure reliably.
|
||||
//
|
||||
// Auth is DELEGATED (auth-code + PKCE, browser) — Microsoft To Do has no app-only
|
||||
// access. Run `login` once; it opens your browser. The refresh token is then
|
||||
// cached and reused automatically thereafter.
|
||||
//
|
||||
// Config (env or a .env file in the CWD or this skill's directory):
|
||||
// TODO_CLIENT_ID (or AZURE_CLIENT_ID) required — a PUBLIC client app id
|
||||
// TODO_TENANT_ID (or AZURE_TENANT_ID) optional — default "common"
|
||||
// TODO_TOKEN_CACHE optional — path to the token cache (default: .token-cache.json here)
|
||||
// TODO_REDIRECT_PORT optional — pin the loopback redirect port (default: OS-assigned)
|
||||
// TODO_TIMEZONE optional — IANA/Windows zone for --due/--start/--reminder (default UTC)
|
||||
//
|
||||
// Usage:
|
||||
// node todo.mjs login Sign in (opens browser), once
|
||||
// node todo.mjs logout Forget the cached sign-in
|
||||
// node todo.mjs test Verify auth; show signed-in user
|
||||
// node todo.mjs lists List task lists
|
||||
// node todo.mjs list-create --name "Groceries"
|
||||
// node todo.mjs list-update --list NAME_OR_ID --name "New name"
|
||||
// node todo.mjs list-delete --list NAME_OR_ID
|
||||
// node todo.mjs tasks --list NAME_OR_ID [--filter ODATA] [--select F1,F2] [--orderby "dueDateTime/dateTime asc"] [--top N] [--all]
|
||||
// node todo.mjs get --list NAME_OR_ID --id TASK_ID
|
||||
// node todo.mjs create --list NAME_OR_ID --title "..." [--due 2026-07-01] [--start ...] [--reminder ...] [--importance high] [--body "..."] [--fields JSON]
|
||||
// node todo.mjs update --list NAME_OR_ID --id TASK_ID [--title ...] [--status completed] [--due ...] [--importance ...] [--body ...] [--fields JSON]
|
||||
// node todo.mjs done --list NAME_OR_ID --id TASK_ID
|
||||
// node todo.mjs delete --list NAME_OR_ID --id TASK_ID
|
||||
// node todo.mjs checklist --list NAME_OR_ID --id TASK_ID List checklist (subtasks)
|
||||
// node todo.mjs checklist-add --list NAME_OR_ID --id TASK_ID --name "step"
|
||||
// node todo.mjs checklist-check --list NAME_OR_ID --id TASK_ID --item ITEM_ID [--uncheck]
|
||||
// node todo.mjs checklist-delete --list NAME_OR_ID --id TASK_ID --item ITEM_ID
|
||||
//
|
||||
// --fields accepts inline JSON, @path/to/file.json, or "-" (stdin) and is merged
|
||||
// over the convenience flags, so you can express anything the Graph API supports.
|
||||
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
MSTodoClient,
|
||||
GraphError,
|
||||
AuthRequiredError,
|
||||
toDateTimeTimeZone,
|
||||
} 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 }
|
||||
: {}),
|
||||
...(err instanceof AuthRequiredError ? { needsLogin: true } : {}),
|
||||
};
|
||||
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 MSTodoClient({
|
||||
clientId: env("TODO_CLIENT_ID", "AZURE_CLIENT_ID"),
|
||||
tenantId: env("TODO_TENANT_ID", "AZURE_TENANT_ID") || "common",
|
||||
tokenCachePath: env("TODO_TOKEN_CACHE") || join(__dirname, ".token-cache.json"),
|
||||
redirectPort: env("TODO_REDIRECT_PORT") ? Number(env("TODO_REDIRECT_PORT")) : 0,
|
||||
});
|
||||
const tz = env("TODO_TIMEZONE") || "UTC";
|
||||
|
||||
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 TASK_ID.");
|
||||
return flags.id;
|
||||
};
|
||||
const needItem = () => {
|
||||
if (!flags.item) throw new Error("Missing --item ITEM_ID.");
|
||||
return flags.item;
|
||||
};
|
||||
|
||||
let result;
|
||||
switch (command) {
|
||||
case "login":
|
||||
result = await client.login((p) => {
|
||||
process.stderr.write(
|
||||
`\nOpening your browser to sign in…\nIf it didn't open, paste this URL:\n${p.authorizeUrl}\n\n` +
|
||||
`Waiting for you to finish signing in (redirect to ${p.redirectUri})…\n\n`,
|
||||
);
|
||||
});
|
||||
result = { ok: true, signedIn: result };
|
||||
break;
|
||||
case "logout":
|
||||
result = { ok: true, loggedOut: client.logout() };
|
||||
break;
|
||||
case "test":
|
||||
result = await client.test();
|
||||
break;
|
||||
case "lists":
|
||||
result = await client.listTaskLists();
|
||||
break;
|
||||
case "list-create":
|
||||
result = await client.createTaskList(needName());
|
||||
break;
|
||||
case "list-update":
|
||||
result = await client.updateTaskList(needList(), needName());
|
||||
break;
|
||||
case "list-delete":
|
||||
await client.deleteTaskList(needList());
|
||||
result = { ok: true, deletedList: needList() };
|
||||
break;
|
||||
case "tasks":
|
||||
result = await client.listTasks(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.getTask(needList(), needId());
|
||||
break;
|
||||
case "create":
|
||||
result = await client.createTask(needList(), buildTaskFields(flags, tz, true));
|
||||
break;
|
||||
case "update":
|
||||
result = await client.updateTask(needList(), needId(), buildTaskFields(flags, tz, false));
|
||||
break;
|
||||
case "done":
|
||||
result = await client.completeTask(needList(), needId());
|
||||
break;
|
||||
case "delete":
|
||||
await client.deleteTask(needList(), needId());
|
||||
result = { ok: true, deleted: needId() };
|
||||
break;
|
||||
case "checklist":
|
||||
result = await client.listChecklistItems(needList(), needId());
|
||||
break;
|
||||
case "checklist-add":
|
||||
result = await client.createChecklistItem(needList(), needId(), needName());
|
||||
break;
|
||||
case "checklist-check":
|
||||
result = await client.updateChecklistItem(needList(), needId(), needItem(), {
|
||||
isChecked: !flags.uncheck,
|
||||
displayName: flags.name,
|
||||
});
|
||||
break;
|
||||
case "checklist-delete":
|
||||
await client.deleteChecklistItem(needList(), needId(), needItem());
|
||||
result = { ok: true, deletedItem: needItem() };
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown command "${command}". Run "node todo.mjs help".`);
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
||||
|
||||
function needName() {
|
||||
if (!flags.name) throw new Error("Missing --name VALUE.");
|
||||
return flags.name;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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, --uncheck)
|
||||
} else {
|
||||
flags[key] = next;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a todoTask body from convenience flags, then merge raw --fields JSON on
|
||||
* top so power users can override anything. On create, title is required.
|
||||
*/
|
||||
function buildTaskFields(flags, tz, requireTitle) {
|
||||
const fields = {};
|
||||
if (flags.title) fields.title = flags.title;
|
||||
if (flags.importance) fields.importance = flags.importance; // low|normal|high
|
||||
if (flags.status) fields.status = flags.status; // notStarted|inProgress|completed|waitingOnOthers|deferred
|
||||
if (flags.body) fields.body = { content: flags.body, contentType: "text" };
|
||||
if (flags.due) fields.dueDateTime = toDateTimeTimeZone(flags.due, tz);
|
||||
if (flags.start) fields.startDateTime = toDateTimeTimeZone(flags.start, tz);
|
||||
if (flags.reminder) {
|
||||
fields.reminderDateTime = toDateTimeTimeZone(flags.reminder, tz);
|
||||
fields.isReminderOn = true;
|
||||
}
|
||||
if (flags.categories) {
|
||||
fields.categories = String(flags.categories)
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const raw = readFieldsFlag(flags);
|
||||
Object.assign(fields, raw);
|
||||
|
||||
if (requireTitle && !fields.title) {
|
||||
throw new Error("Missing --title (or a title in --fields) for create.");
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function readFieldsFlag(flags) {
|
||||
const raw = flags.fields;
|
||||
if (raw === undefined || raw === true) return {};
|
||||
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(
|
||||
`ms-todo CLI — Microsoft To Do via Microsoft Graph (delegated / browser auth-code+PKCE)
|
||||
|
||||
Auth (one-time):
|
||||
login Sign in (opens your browser)
|
||||
logout Forget the cached sign-in
|
||||
test Verify auth; show signed-in user
|
||||
|
||||
Task lists:
|
||||
lists List all task lists
|
||||
list-create --name "Groceries"
|
||||
list-update --list NAME_OR_ID --name "New name"
|
||||
list-delete --list NAME_OR_ID
|
||||
|
||||
Tasks:
|
||||
tasks --list NAME_OR_ID [filters] Query tasks
|
||||
get --list NAME_OR_ID --id TASK_ID Get one task
|
||||
create --list NAME_OR_ID --title "..." [opts] Create a task
|
||||
update --list NAME_OR_ID --id TASK_ID [opts] Update a task
|
||||
done --list NAME_OR_ID --id TASK_ID Mark a task completed
|
||||
delete --list NAME_OR_ID --id TASK_ID Delete a task
|
||||
|
||||
task opts: --title --body --importance low|normal|high --status notStarted|inProgress|completed
|
||||
--due 2026-07-01 --start ... --reminder ... --categories "a,b"
|
||||
tasks filters: --filter "status ne 'completed'" --select title,status,dueDateTime
|
||||
--orderby "dueDateTime/dateTime asc" --top 50 --all
|
||||
|
||||
Checklist items (subtasks):
|
||||
checklist --list NAME_OR_ID --id TASK_ID
|
||||
checklist-add --list NAME_OR_ID --id TASK_ID --name "step"
|
||||
checklist-check --list NAME_OR_ID --id TASK_ID --item ITEM_ID [--uncheck]
|
||||
checklist-delete --list NAME_OR_ID --id TASK_ID --item ITEM_ID
|
||||
|
||||
--fields accepts inline JSON, @file.json, or - (stdin); it merges over the flags.
|
||||
Config (env / .env): TODO_CLIENT_ID (required), TODO_TENANT_ID (default "common"),
|
||||
TODO_TOKEN_CACHE, TODO_TIMEZONE (default UTC).
|
||||
`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user