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