#!/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));