#!/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). `, ); }