#!/usr/bin/env node /** * mps-gym — zero-dependency CLI for the "A Gym for Murrumbeena" campaign API. * Node 18+ (uses global fetch). No install needed: * * node cli/mps-gym.mjs stats * node cli/mps-gym.mjs sign --name "Sam Citizen" --email sam@example.com --suburb Murrumbeena --postcode 3163 * * Global flags: * --api Override API base URL (default https://dev.gymformurrumbeena.win) * --json Print raw JSON response * --help Help for the CLI or a specific command */ const DEFAULT_API = "https://dev.gymformurrumbeena.win"; import { readFile } from "node:fs/promises"; import { basename } from "node:path"; const COMMANDS = { info: { desc: "Campaign summary: the ask, school facts, key dates, links", flags: {}, run: async (api) => { const res = await req("POST", `${api}/mcp`, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "get_campaign_info", arguments: {} }, }); if (res.error) fail(`MCP error ${res.error.code}: ${res.error.message}`); return JSON.parse(res.result.content[0].text); }, }, stats: { desc: "Live counters vs goals (signatures, supporters, ideas, endorsements, pledges)", flags: {}, run: (api) => req("GET", `${api}/api/stats`), }, news: { desc: "Latest published campaign news", flags: { limit: "Max posts" }, run: (api, f) => req("GET", `${api}/api/news?limit=${f.limit || 50}`), }, wall: { desc: "Messages from the wall of support", flags: { limit: "Max messages" }, run: (api, f) => req("GET", `${api}/api/wall?limit=${f.limit || 30}`), }, ideas: { desc: "Community ideas for the hub", flags: { limit: "Max ideas" }, run: (api, f) => req("GET", `${api}/api/ideas?limit=${f.limit || 20}`), }, knowledge: { desc: "Search the public campaign knowledge base (research, equity data, arguments, planning)", flags: { query: "Search keywords (required), e.g. --query \"equity neighbouring schools\"", category: "Optional boost: ask|background|evidence|arguments|planning|social|news-context", }, run: (api, f) => { need(f, ["query"]); const cat = f.category ? `&category=${encodeURIComponent(f.category)}` : ""; return req("GET", `${api}/api/knowledge/search?q=${encodeURIComponent(f.query)}${cat}`); }, }, "knowledge-doc": { desc: "Read a full knowledge-base document by slug (from knowledge search results)", flags: { slug: "Document slug (required)" }, run: (api, f) => { need(f, ["slug"]); return req("GET", `${api}/api/knowledge/doc?slug=${encodeURIComponent(f.slug)}`); }, }, graph: { desc: "Look up a campaign entity and its relationships in the knowledge graph", flags: { entity: "Entity name or alias (required), e.g. --entity \"Community Hub\"" }, run: (api, f) => { need(f, ["entity"]); return req("GET", `${api}/api/knowledge/graph?entity=${encodeURIComponent(f.entity)}`); }, }, sign: { desc: "Sign the petition", flags: { name: "Full name (required)", email: "Email address (required, kept private)", suburb: "Suburb", postcode: "Postcode", message: "Public message with the signature", local: "Flag signer as local (boolean)", }, run: (api, f) => { need(f, ["name", "email"]); return req("POST", `${api}/api/sign`, { name: f.name, email: f.email, suburb: f.suburb, postcode: f.postcode, message: f.message, is_local: !!f.local, }); }, }, support: { desc: "Leave a message on the wall of support", flags: { name: "Name (required)", email: "Email address (required, kept private)", message: "Message of support (required)", suburb: "Suburb", organisation: "Organisation", }, run: (api, f) => { need(f, ["name", "email", "message"]); return req("POST", `${api}/api/support`, { name: f.name, email: f.email, message: f.message, suburb: f.suburb, organisation: f.organisation, }); }, }, idea: { desc: "Share an idea for how the gym/community hub could be used", flags: { name: "Name (required)", details: "Idea description (required)", uses: "Comma-separated uses, e.g. basketball,arts,events", email: "Email address (required, kept private)", organisation: "Organisation", }, run: (api, f) => { need(f, ["name", "details", "email"]); return req("POST", `${api}/api/ideas`, { name: f.name, details: f.details, email: f.email, organisation: f.organisation, uses: f.uses ? String(f.uses).split(",").map((s) => s.trim()).filter(Boolean) : [], }); }, }, pledge: { desc: "Pledge a one-off donation or wish-list item toward campaign running costs", flags: { name: "Name (required)", email: "Email (required)", gift: "Wish-list item being sponsored (preferred over --amount)", amount: "One-off amount in AUD (leave blank if you picked a wish-list item)", title: "Your title, e.g. Owner, President", phone: "Contact number", organisation: "Organisation (if pledging as one)", "in-kind": "In-kind details, e.g. printing services", notes: "Notes", attachment: "File to attach — quote/proposal/photo (pdf/png/jpg/webp/doc/docx/zip/txt, max 8 MB)", }, run: async (api, f) => { need(f, ["name", "email"]); for (const gone of ["frequency", "org-type", "org-size", "budget-band"]) { if (f[gone] != null) { console.error(`Note: --${gone} is no longer collected (every pledge is one-off) — ignoring.`); } } const fields = { name: f.name, email: f.email, gift_item: f.gift, amount: f.amount != null && f.amount !== true ? Number(f.amount) : undefined, title: f.title, phone: f.phone, organisation: f.organisation, in_kind_details: f["in-kind"], notes: f.notes, }; if (f.attachment) { return reqMultipart(`${api}/api/pledge`, fields, "attachment", String(f.attachment)); } return req("POST", `${api}/api/pledge`, fields); }, }, contact: { desc: "Message the campaign working group (use --subject 'Campaign updates' to subscribe)", flags: { name: "Name (required)", email: "Email (required)", message: "Message (required)", subject: "Subject", }, run: (api, f) => { need(f, ["name", "email", "message"]); return req("POST", `${api}/api/contact`, { name: f.name, email: f.email, message: f.message, subject: f.subject, }); }, }, }; // ---------- helpers ---------- async function req(method, url, body) { const res = await fetch(url, { method, headers: body ? { "content-type": "application/json" } : undefined, body: body ? JSON.stringify(body) : undefined, }); const text = await res.text(); let data; try { data = JSON.parse(text); } catch { data = text; } if (!res.ok) { const msg = typeof data === "object" && data && (data.detail || data.message || data.error); fail(`HTTP ${res.status} from ${url}${msg ? ` — ${msg}` : ""}`); } return data; } function need(flags, names) { for (const n of names) { if (flags[n] == null || String(flags[n]).trim() === "") { fail(`Missing required flag --${n}\nRun with --help for usage.`); } } } function fail(msg) { console.error(`Error: ${msg}`); process.exit(1); } /** POST multipart/form-data (fields + one file), same error handling as req(). */ async function reqMultipart(url, fields, fileField, filePath) { const fd = new FormData(); for (const [k, v] of Object.entries(fields)) { if (v !== undefined && v !== null && v !== "") fd.append(k, String(v)); } let fileData; try { fileData = await readFile(filePath); } catch { fail(`Cannot read attachment file: ${filePath}`); } fd.append(fileField, new Blob([fileData]), basename(filePath)); const res = await fetch(url, { method: "POST", body: fd }); const text = await res.text(); let data; try { data = JSON.parse(text); } catch { data = text; } if (!res.ok) { const msg = typeof data === "object" && data && (data.detail || data.message || data.error); fail(`HTTP ${res.status} from ${url}${msg ? ` — ${msg}` : ""}`); } return data; } function parseArgs(argv) { const flags = {}; const positional = []; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a.startsWith("--")) { const key = a.slice(2); const next = argv[i + 1]; if (next === undefined || next.startsWith("--")) { flags[key] = true; // boolean flag } else { flags[key] = next; i++; } } else { positional.push(a); } } return { flags, positional }; } function printHelp(command) { console.log(`mps-gym — CLI for the "A Gym for Murrumbeena" campaign API Usage: node cli/mps-gym.mjs [flags] [--api ] [--json] Global flags: --api API base URL (default ${DEFAULT_API}) --json Print raw JSON --help Show help `); if (command && COMMANDS[command]) { const c = COMMANDS[command]; console.log(`${command} — ${c.desc}`); const names = Object.keys(c.flags); if (names.length) { console.log("\nFlags:"); for (const n of names) console.log(` --${n.padEnd(14)} ${c.flags[n]}`); } else { console.log("\nNo flags."); } return; } console.log("Commands:"); for (const [n, c] of Object.entries(COMMANDS)) { console.log(` ${n.padEnd(9)} ${c.desc}`); } console.log(` mcp Run a local MCP server on stdio (proxy to the live /mcp endpoint)`); console.log(`\nExamples: node cli/mps-gym.mjs stats node cli/mps-gym.mjs news --limit 5 --json node cli/mps-gym.mjs knowledge --query "equity neighbouring schools" node cli/mps-gym.mjs knowledge-doc --slug the-ask node cli/mps-gym.mjs graph --entity "Community Hub" node cli/mps-gym.mjs sign --name "Sam Citizen" --email sam@example.com --suburb Murrumbeena --postcode 3163 --local node cli/mps-gym.mjs support --name "Sam" --email sam@example.com --message "Our kids deserve this gym!" --suburb Murrumbeena node cli/mps-gym.mjs idea --name "Sam" --email sam@example.com --details "Weekend junior basketball comp" --uses basketball,juniors node cli/mps-gym.mjs pledge --name "Sam" --email sam@example.com --amount 100 node cli/mps-gym.mjs pledge --name "Sam" --email sam@example.com --gift "Corflute yard signs (sponsor-a-sign)" node cli/mps-gym.mjs contact --name "Sam" --email sam@example.com --subject "Campaign updates" --message "Please add me to the newsletter" node cli/mps-gym.mjs mcp # stdio MCP server for Claude Desktop / Cursor / Kimi`); } // ---------- local MCP stdio proxy ---------- // `mps-gym mcp` runs a Model Context Protocol server on stdio that forwards // tools/list and tools/call to the live campaign MCP endpoint. Register it in // Claude Desktop, Cursor, Kimi or any MCP client to give your agent campaign // tools without any cloud setup of your own. const MCP_SERVER_INFO = { name: "mps-gym-campaign", version: "1.1.0" }; async function serveMcpStdio(api) { const readline = await import("node:readline"); const rl = readline.createInterface({ input: process.stdin }); const forward = async (msg) => { const res = await fetch(`${api}/mcp`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(msg), }); return res.json().catch(() => null); }; for await (const line of rl) { const text = line.trim(); if (!text) continue; let msg; try { msg = JSON.parse(text); } catch { continue; } const isRequest = msg.id !== undefined && msg.id !== null; try { // Answer the handshake locally so every MCP client version connects. if (msg.method === "initialize" && isRequest) { process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: (msg.params && msg.params.protocolVersion) || "2024-11-05", capabilities: { tools: {} }, serverInfo: MCP_SERVER_INFO, }, }) + "\n"); continue; } if (msg.method === "notifications/initialized" || msg.method === "ping") { if (isRequest && msg.method === "ping") { process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: {} }) + "\n"); } continue; } const resp = await forward(msg); if (isRequest && resp) process.stdout.write(JSON.stringify(resp) + "\n"); } catch (e) { if (isRequest) { process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, error: { code: -32603, message: String((e && e.message) || e) }, }) + "\n"); } } } } // ---------- pretty printers ---------- function pretty(command, data) { switch (command) { case "stats": { const g = data.goals || {}; const line = (label, v, goal, display) => ` ${label.padEnd(18)} ${String(display ?? v).padStart(8)}${goal ? ` / ${goal} (${Math.round((v / goal) * 100)}%)` : ""}`; console.log("Campaign stats:"); console.log(line("Signatures", data.signatures, g.signatures)); console.log(line("Supporters", data.supporters, g.supporters)); console.log(line("Ideas", data.ideas, g.ideas)); console.log(line("Endorsements", data.endorsements, g.endorsements)); console.log(line("Pledges", data.pledges)); console.log(line("Pledged (AUD)", data.pledged, g.pledges, `$${data.pledged}`)); return; } case "news": for (const n of data.news || []) { console.log(`• [${n.created_at}] ${n.title}\n ${String(n.body).slice(0, 160)}${n.body && n.body.length > 160 ? "…" : ""}`); } if (!(data.news || []).length) console.log("No news yet."); return; case "wall": for (const m of data.messages || []) { console.log(`• ${m.name}${m.suburb ? ` (${m.suburb})` : ""}: ${m.message}`); } if (!(data.messages || []).length) console.log("No messages yet."); return; case "ideas": for (const i of data.ideas || []) { console.log(`• ${i.name}${i.organisation ? ` (${i.organisation})` : ""}: ${i.details}${i.uses && i.uses.length ? ` [${i.uses.join(", ")}]` : ""}`); } if (!(data.ideas || []).length) console.log("No ideas yet."); return; case "knowledge": { for (const r of data.results || []) { console.log(`• [${r.category}] ${r.slug} — ${r.title}\n ${r.summary || ""}`); } if (!(data.results || []).length) console.log(data.hint || "No matching documents."); else console.log(`\nRead a document: knowledge-doc --slug `); return; } case "knowledge-doc": { if (data.error) { console.log(`${data.error}: ${data.hint || ""}`); return; } console.log(`# ${data.title} [${data.category}]`); if (data.summary) console.log(`\n${data.summary}\n`); console.log(data.content || ""); if (data.source) console.log(`\nSource: ${data.source} · updated ${data.updated_at || "?"}`); return; } case "graph": { if (data.error) { console.log(`${data.error}: ${data.hint || ""}`); if (data.known_entities) console.log(`Known entities: ${data.known_entities.join(", ")}`); return; } const e = data.entity || {}; console.log(`# ${e.name} [${e.type || "concept"}]`); if (e.description) console.log(e.description); for (const r of data.relations || []) { console.log(` ${r.from_name} —[${r.relation}]→ ${r.to_name}${r.note ? ` (${r.note})` : ""}`); } if (!(data.relations || []).length) console.log(" (no relations recorded yet)"); return; } default: console.log(JSON.stringify(data, null, 2)); } } // ---------- main ---------- async function main() { const { flags, positional } = parseArgs(process.argv.slice(2)); const command = positional[0]; const api = String(flags.api || DEFAULT_API).replace(/\/+$/, ""); if (command === "version" || flags.version) { console.log(MCP_SERVER_INFO.version); return; } // Long-running local MCP server mode: mps-gym mcp [--api ] if (command === "mcp") { await serveMcpStdio(api); return; } if (!command || flags.help || command === "help") { printHelp(command && COMMANDS[command] ? command : null); process.exit(command && !COMMANDS[command] && command !== "help" ? 1 : 0); } const cmd = COMMANDS[command]; if (!cmd) fail(`Unknown command "${command}". Run --help to list commands.`); const data = await cmd.run(api, flags); if (flags.json) { console.log(JSON.stringify(data, null, 2)); } else { pretty(command, data); } } main().catch((e) => fail(e.cause ? `${e.message} (${e.cause.message || e.cause})` : e.message));