Files
claude-ms-todo/.claude/skills/ms-todo/scripts/todo.mjs

307 lines
11 KiB
JavaScript
Raw Normal View History

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