@anouar-bm/bayane 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # bayane
2
+
3
+ CLI and MCP server for hospital satisfaction survey data.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g @anouar-bm/bayane
9
+ ```
10
+
11
+ Or run without installing:
12
+
13
+ ```bash
14
+ npx @anouar-bm/bayane setup
15
+ ```
16
+
17
+ ## Quick start
18
+
19
+ ```bash
20
+ bayane setup # enter your API URL and key
21
+ bayane stats # satisfaction stats for the last 7 days
22
+ ```
23
+
24
+ ## Commands
25
+
26
+ ```bash
27
+ bayane setup # configure API URL and key (~/.health-survey-rc)
28
+ bayane whoami # show current config
29
+ bayane stats # stats for last 7 days
30
+ bayane stats --period 30d # stats for last 30 days
31
+ bayane stats --period all # all-time stats
32
+ bayane alerts # critical alerts from last 7 days
33
+ bayane report --latest # download latest PDF report
34
+ bayane report --date 2026-03-21 # download report for a specific date
35
+ bayane report --output ~/out.pdf # save to a custom path
36
+ ```
37
+
38
+ ## Auth
39
+
40
+ `bayane setup` stores an API key (`sk_*`) in `~/.health-survey-rc` (mode 0600). Keys are created from the admin dashboard and never expire.
41
+
42
+ ## MCP server
43
+
44
+ `bayane` also ships as an [MCP](https://modelcontextprotocol.io) server so Claude Code and claude.ai can query hospital data directly.
45
+
46
+ Add to `~/.claude/mcp.json`:
47
+
48
+ ```json
49
+ {
50
+ "health-survey": {
51
+ "command": "node",
52
+ "args": ["<path-to>/node_modules/@anouar-bm/bayane/dist/mcp/index.js"]
53
+ }
54
+ }
55
+ ```
56
+
57
+ Available MCP tools: `get_stats`, `get_alerts`, `download_report`, `display_report`.
58
+
59
+ ## Claude Code skill
60
+
61
+ Install the companion skill for natural-language queries inside Claude Code:
62
+
63
+ ```bash
64
+ npx skills add https://github.com/anouar-bm/bayane
65
+ ```
66
+
67
+ Then use `/bayane` inside any Claude Code session to ask questions like _"show me NPS for the last 30 days"_.
68
+
69
+ ## Requirements
70
+
71
+ - Node.js >= 18
72
+ - Access to a running [1337 hospital survey API](https://github.com/anouar-bm/1337)
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { password, input } from "@inquirer/prompts";
4
+ import { writeFileSync } from "fs";
5
+ import { spawnSync } from "child_process";
6
+ import { loadConfig, saveConfig, CONFIG_PATH } from "../core/config.js";
7
+ import { fetchStats, fetchReportPdf, fetchLatestReportDate } from "../core/api.js";
8
+ import { formatStats, formatAlerts } from "../core/format.js";
9
+ const DEFAULT_API_URL = "https://health-bd.nonas.app";
10
+ const program = new Command();
11
+ program.name("bayane").description("Hospital satisfaction survey CLI").version("1.0.0");
12
+ // bayane setup
13
+ program
14
+ .command("setup")
15
+ .description("Configure API URL and API key")
16
+ .action(async () => {
17
+ const apiUrl = await input({ message: "API URL:", default: DEFAULT_API_URL });
18
+ const apiKey = await password({ message: "API key (sk_...):" });
19
+ saveConfig({ apiUrl, apiKey });
20
+ console.log(`✓ Config saved to ${CONFIG_PATH}`);
21
+ });
22
+ // bayane whoami
23
+ program
24
+ .command("whoami")
25
+ .description("Show current config")
26
+ .action(() => {
27
+ const config = loadConfig();
28
+ console.log(`API URL: ${config.apiUrl}`);
29
+ console.log(`API key: ${config.apiKey.slice(0, 8)}...`);
30
+ });
31
+ // bayane stats
32
+ program
33
+ .command("stats")
34
+ .description("Show satisfaction statistics")
35
+ .option("--period <period>", "7d, 30d, or all", "7d")
36
+ .action(async (opts) => {
37
+ try {
38
+ const config = loadConfig();
39
+ const data = await fetchStats(config, opts.period);
40
+ console.log(formatStats(data, opts.period));
41
+ }
42
+ catch (err) {
43
+ console.error(err instanceof Error ? err.message : String(err));
44
+ process.exit(1);
45
+ }
46
+ });
47
+ // bayane report
48
+ program
49
+ .command("report")
50
+ .description("Download a daily PDF report")
51
+ .option("--date <date>", "YYYY-MM-DD (defaults to latest available)")
52
+ .option("--latest", "Download the most recent available report")
53
+ .option("--output <path>", "Output file path")
54
+ .action(async (opts) => {
55
+ try {
56
+ const config = loadConfig();
57
+ const date = opts.date ??
58
+ (opts.latest
59
+ ? await fetchLatestReportDate(config)
60
+ : new Date().toISOString().split("T")[0]);
61
+ const outPath = opts.output ?? `report-${date}.pdf`;
62
+ const buffer = await fetchReportPdf(config, date);
63
+ writeFileSync(outPath, buffer);
64
+ console.log(`✓ Report saved to ${outPath}`);
65
+ }
66
+ catch (err) {
67
+ console.error(err instanceof Error ? err.message : String(err));
68
+ process.exit(1);
69
+ }
70
+ });
71
+ // bayane alerts
72
+ program
73
+ .command("alerts")
74
+ .description("Show critical alerts from the last 7 days")
75
+ .action(async () => {
76
+ try {
77
+ const config = loadConfig();
78
+ const data = await fetchStats(config, "7d");
79
+ console.log(formatAlerts(data));
80
+ }
81
+ catch (err) {
82
+ console.error(err instanceof Error ? err.message : String(err));
83
+ process.exit(1);
84
+ }
85
+ });
86
+ // bayane install
87
+ program
88
+ .command("install")
89
+ .description("Install the bayane skill for your AI agent")
90
+ .action(() => {
91
+ console.log("Installing bayane skill...");
92
+ spawnSync("npx", ["skills", "add", "anouar-bm/bayane"], { stdio: "inherit" });
93
+ });
94
+ program.parse();
@@ -0,0 +1,62 @@
1
+ const TIMEOUT_MS = 10_000;
2
+ async function apiFetch(config, path) {
3
+ const controller = new AbortController();
4
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
5
+ try {
6
+ const res = await fetch(`${config.apiUrl}${path}`, {
7
+ headers: { Authorization: `Bearer ${config.apiKey}` },
8
+ signal: controller.signal,
9
+ });
10
+ if (res.status === 401)
11
+ throw new Error("Invalid API key. Run: bayane setup");
12
+ if (!res.ok) {
13
+ const text = await res.text().catch(() => res.statusText);
14
+ throw new Error(`API error ${res.status}: ${text}`);
15
+ }
16
+ return res.json();
17
+ }
18
+ catch (err) {
19
+ if (err instanceof Error && err.name === "AbortError") {
20
+ throw new Error("Request timed out. Check your connection.");
21
+ }
22
+ throw err;
23
+ }
24
+ finally {
25
+ clearTimeout(timer);
26
+ }
27
+ }
28
+ export async function fetchStats(config, period) {
29
+ return apiFetch(config, `/api/analytics?range=${period}`);
30
+ }
31
+ export async function fetchReportPdf(config, date) {
32
+ const controller = new AbortController();
33
+ const timer = setTimeout(() => controller.abort(), 30_000);
34
+ try {
35
+ const res = await fetch(`${config.apiUrl}/api/reports/${date}/pdf`, {
36
+ headers: { Authorization: `Bearer ${config.apiKey}` },
37
+ signal: controller.signal,
38
+ });
39
+ if (res.status === 401)
40
+ throw new Error("Invalid API key. Run: bayane setup");
41
+ if (res.status === 404)
42
+ throw new Error(`No report found for ${date}`);
43
+ if (!res.ok)
44
+ throw new Error(`Failed to download report: ${res.status}`);
45
+ return Buffer.from(await res.arrayBuffer());
46
+ }
47
+ catch (err) {
48
+ if (err instanceof Error && err.name === "AbortError") {
49
+ throw new Error("Report download timed out.");
50
+ }
51
+ throw err;
52
+ }
53
+ finally {
54
+ clearTimeout(timer);
55
+ }
56
+ }
57
+ export async function fetchLatestReportDate(config) {
58
+ const reports = await apiFetch(config, "/api/reports");
59
+ if (!reports.length)
60
+ throw new Error("No reports available yet.");
61
+ return reports[0].date;
62
+ }
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { fetchStats, fetchReportPdf, fetchLatestReportDate } from "./api.js";
3
+ const mockConfig = { apiUrl: "https://test.example.com", apiKey: "sk_test" };
4
+ beforeEach(() => {
5
+ vi.resetAllMocks();
6
+ });
7
+ describe("fetchStats", () => {
8
+ it("throws on 401", async () => {
9
+ vi.stubGlobal("fetch", async () => ({ ok: false, status: 401, json: async () => ({}) }));
10
+ await expect(fetchStats(mockConfig, "7d")).rejects.toThrow("Invalid API key");
11
+ });
12
+ it("throws on network timeout", async () => {
13
+ vi.stubGlobal("fetch", () => new Promise((_, reject) => {
14
+ const err = new Error("aborted");
15
+ err.name = "AbortError";
16
+ setTimeout(() => reject(err), 0);
17
+ }));
18
+ await expect(fetchStats(mockConfig, "7d")).rejects.toThrow("timed out");
19
+ });
20
+ it("returns data on success", async () => {
21
+ const mockData = {
22
+ totalSubmissions: 10,
23
+ averageScore: 8,
24
+ nps: 50,
25
+ serviceScores: [],
26
+ criticalAlerts: [],
27
+ };
28
+ vi.stubGlobal("fetch", async () => ({ ok: true, status: 200, json: async () => mockData }));
29
+ const result = await fetchStats(mockConfig, "7d");
30
+ expect(result.totalSubmissions).toBe(10);
31
+ });
32
+ });
33
+ describe("fetchReportPdf", () => {
34
+ it("throws on 401", async () => {
35
+ vi.stubGlobal("fetch", async () => ({ ok: false, status: 401, arrayBuffer: async () => new ArrayBuffer(0) }));
36
+ await expect(fetchReportPdf(mockConfig, "2026-03-22")).rejects.toThrow("Invalid API key");
37
+ });
38
+ it("throws on 404", async () => {
39
+ vi.stubGlobal("fetch", async () => ({ ok: false, status: 404 }));
40
+ await expect(fetchReportPdf(mockConfig, "2026-03-22")).rejects.toThrow("No report found");
41
+ });
42
+ it("returns buffer on success", async () => {
43
+ const bytes = new Uint8Array([1, 2, 3]);
44
+ vi.stubGlobal("fetch", async () => ({ ok: true, status: 200, arrayBuffer: async () => bytes.buffer }));
45
+ const buf = await fetchReportPdf(mockConfig, "2026-03-22");
46
+ expect(buf).toBeInstanceOf(Buffer);
47
+ expect(buf.length).toBe(3);
48
+ });
49
+ });
50
+ describe("fetchLatestReportDate", () => {
51
+ it("throws when no reports available", async () => {
52
+ vi.stubGlobal("fetch", async () => ({ ok: true, status: 200, json: async () => [] }));
53
+ await expect(fetchLatestReportDate(mockConfig)).rejects.toThrow("No reports available");
54
+ });
55
+ it("returns the first report date", async () => {
56
+ vi.stubGlobal("fetch", async () => ({
57
+ ok: true,
58
+ status: 200,
59
+ json: async () => [{ date: "2026-03-22" }, { date: "2026-03-15" }],
60
+ }));
61
+ const date = await fetchLatestReportDate(mockConfig);
62
+ expect(date).toBe("2026-03-22");
63
+ });
64
+ });
@@ -0,0 +1,20 @@
1
+ import { homedir } from "os";
2
+ import { join } from "path";
3
+ import { readFileSync, writeFileSync, existsSync } from "fs";
4
+ export const CONFIG_PATH = join(homedir(), ".health-survey-rc");
5
+ export function loadConfig() {
6
+ if (!existsSync(CONFIG_PATH)) {
7
+ console.error("Not configured. Run: bayane setup");
8
+ process.exit(1);
9
+ }
10
+ try {
11
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
12
+ }
13
+ catch {
14
+ console.error("Config file is corrupted. Run: bayane setup");
15
+ process.exit(1);
16
+ }
17
+ }
18
+ export function saveConfig(config) {
19
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
20
+ }
@@ -0,0 +1,30 @@
1
+ export function formatStats(data, period) {
2
+ const lines = [
3
+ ``,
4
+ `📊 Hospital Satisfaction — last ${period}`,
5
+ ` Responses: ${data.totalSubmissions}`,
6
+ ` Avg score: ${data.averageScore}/10`,
7
+ ` NPS: ${data.nps}`,
8
+ ];
9
+ if (data.serviceScores.length > 0) {
10
+ lines.push(``, ` By service:`);
11
+ [...data.serviceScores]
12
+ .sort((a, b) => b.avgScore - a.avgScore)
13
+ .forEach((s) => lines.push(` ${s.service.padEnd(20)} ${s.avgScore}/10 (${s.count} responses)`));
14
+ }
15
+ if (data.criticalAlerts.length > 0) {
16
+ lines.push(``, `⚠️ Critical alerts: ${data.criticalAlerts.length}`);
17
+ data.criticalAlerts.forEach((a) => lines.push(` - ${a.message}`));
18
+ }
19
+ return lines.join("\n");
20
+ }
21
+ export function formatAlerts(data) {
22
+ if (data.criticalAlerts.length === 0) {
23
+ return "✓ No critical alerts in the last 7 days.";
24
+ }
25
+ const lines = [``, `⚠️ ${data.criticalAlerts.length} critical alert(s):`, ``];
26
+ data.criticalAlerts.forEach((a) => {
27
+ lines.push(` - ${a.message}${a.service ? ` [${a.service}]` : ""}${a.date ? ` — ${a.date}` : ""}`);
28
+ });
29
+ return lines.join("\n");
30
+ }
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { formatStats, formatAlerts } from "./format.js";
3
+ const mockData = {
4
+ totalSubmissions: 42,
5
+ averageScore: 7.8,
6
+ nps: 35,
7
+ serviceScores: [
8
+ { service: "Emergency", avgScore: 8.2, count: 20 },
9
+ { service: "Maternity", avgScore: 7.1, count: 22 },
10
+ ],
11
+ criticalAlerts: [],
12
+ };
13
+ describe("formatStats", () => {
14
+ it("shows key metrics", () => {
15
+ const out = formatStats(mockData, "7d");
16
+ expect(out).toContain("42");
17
+ expect(out).toContain("7.8/10");
18
+ expect(out).toContain("35");
19
+ });
20
+ it("sorts services by score descending", () => {
21
+ const out = formatStats(mockData, "7d");
22
+ expect(out.indexOf("Emergency")).toBeLessThan(out.indexOf("Maternity"));
23
+ });
24
+ it("shows alerts when present", () => {
25
+ const data = { ...mockData, criticalAlerts: [{ message: "Score below 5" }] };
26
+ expect(formatStats(data, "7d")).toContain("Score below 5");
27
+ });
28
+ });
29
+ describe("formatAlerts", () => {
30
+ it("returns ok message when no alerts", () => {
31
+ expect(formatAlerts(mockData)).toContain("No critical alerts");
32
+ });
33
+ it("lists alerts with service and date", () => {
34
+ const data = {
35
+ ...mockData,
36
+ criticalAlerts: [{ message: "Low score", service: "ER", date: "2026-03-21" }],
37
+ };
38
+ const out = formatAlerts(data);
39
+ expect(out).toContain("Low score");
40
+ expect(out).toContain("[ER]");
41
+ expect(out).toContain("2026-03-21");
42
+ });
43
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,50 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { z } from "zod";
4
+ import { writeFileSync } from "fs";
5
+ import { loadConfig } from "../core/config.js";
6
+ import { fetchStats, fetchReportPdf, fetchLatestReportDate } from "../core/api.js";
7
+ import { formatStats, formatAlerts } from "../core/format.js";
8
+ const server = new McpServer({
9
+ name: "health-survey",
10
+ version: "1.0.0",
11
+ });
12
+ server.tool("get_stats", "Get hospital satisfaction statistics", { period: z.enum(["7d", "30d", "all"]).default("7d").describe("Time period") }, async ({ period }) => {
13
+ const config = loadConfig();
14
+ const data = await fetchStats(config, period);
15
+ return { content: [{ type: "text", text: formatStats(data, period) }] };
16
+ });
17
+ server.tool("get_alerts", "Get critical satisfaction alerts from the last 7 days", {}, async () => {
18
+ const config = loadConfig();
19
+ const data = await fetchStats(config, "7d");
20
+ return { content: [{ type: "text", text: formatAlerts(data) }] };
21
+ });
22
+ server.tool("download_report", "Download a hospital satisfaction PDF report", {
23
+ date: z.string().optional().describe("YYYY-MM-DD — omit for latest"),
24
+ output: z.string().optional().describe("Output file path"),
25
+ }, async ({ date, output }) => {
26
+ const config = loadConfig();
27
+ const reportDate = date ?? (await fetchLatestReportDate(config));
28
+ const outPath = output ?? `report-${reportDate}.pdf`;
29
+ const buffer = await fetchReportPdf(config, reportDate);
30
+ writeFileSync(outPath, buffer);
31
+ return { content: [{ type: "text", text: `✓ Report saved to ${outPath}` }] };
32
+ });
33
+ server.tool("display_report", "Display a hospital satisfaction PDF report (returns base64 for rendering)", {
34
+ date: z.string().optional().describe("YYYY-MM-DD — omit for latest"),
35
+ }, async ({ date }) => {
36
+ const config = loadConfig();
37
+ const reportDate = date ?? (await fetchLatestReportDate(config));
38
+ const buffer = await fetchReportPdf(config, reportDate);
39
+ const base64 = buffer.toString("base64");
40
+ return {
41
+ content: [
42
+ {
43
+ type: "text",
44
+ text: `Hospital Satisfaction Report — ${reportDate}\n\nPDF (base64-encoded, ${buffer.length} bytes):\ndata:application/pdf;base64,${base64}`,
45
+ },
46
+ ],
47
+ };
48
+ });
49
+ const transport = new StdioServerTransport();
50
+ await server.connect(transport);
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@anouar-bm/bayane",
3
+ "version": "1.0.0",
4
+ "description": "CLI and MCP server for hospital satisfaction survey data",
5
+ "type": "module",
6
+ "bin": {
7
+ "bayane": "./dist/cli/index.js"
8
+ },
9
+ "exports": {
10
+ "./mcp": "./dist/mcp/index.js"
11
+ },
12
+ "scripts": {
13
+ "build": "tsc",
14
+ "dev:cli": "tsx src/cli/index.ts",
15
+ "dev:mcp": "tsx src/mcp/index.ts",
16
+ "test": "vitest run",
17
+ "prepublishOnly": "npm run build"
18
+ },
19
+ "dependencies": {
20
+ "commander": "^12.0.0",
21
+ "@inquirer/prompts": "^5.0.0",
22
+ "@modelcontextprotocol/sdk": "^1.0.0",
23
+ "zod": "^3.0.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^20.0.0",
27
+ "typescript": "^5.4.0",
28
+ "tsx": "^4.0.0",
29
+ "vitest": "^1.6.0"
30
+ },
31
+ "engines": { "node": ">=18" }
32
+ }
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { password, input } from "@inquirer/prompts";
4
+ import { writeFileSync } from "fs";
5
+ import { spawnSync } from "child_process";
6
+ import { execSync } from "child_process";
7
+ import { loadConfig, saveConfig, CONFIG_PATH } from "../core/config.js";
8
+ import { fetchStats, fetchReportPdf, fetchLatestReportDate } from "../core/api.js";
9
+ import { formatStats, formatAlerts } from "../core/format.js";
10
+
11
+ const DEFAULT_API_URL = "https://health-bd.nonas.app";
12
+
13
+ const program = new Command();
14
+ program.name("bayane").description("Hospital satisfaction survey CLI").version("1.0.0");
15
+
16
+ // bayane setup
17
+ program
18
+ .command("setup")
19
+ .description("Configure API URL and API key")
20
+ .action(async () => {
21
+ const apiUrl = await input({ message: "API URL:", default: DEFAULT_API_URL });
22
+ const apiKey = await password({ message: "API key (sk_...):" });
23
+ saveConfig({ apiUrl, apiKey });
24
+ console.log(`✓ Config saved to ${CONFIG_PATH}`);
25
+ });
26
+
27
+ // bayane whoami
28
+ program
29
+ .command("whoami")
30
+ .description("Show current config")
31
+ .action(() => {
32
+ const config = loadConfig();
33
+ console.log(`API URL: ${config.apiUrl}`);
34
+ console.log(`API key: ${config.apiKey.slice(0, 8)}...`);
35
+ });
36
+
37
+ // bayane stats
38
+ program
39
+ .command("stats")
40
+ .description("Show satisfaction statistics")
41
+ .option("--period <period>", "7d, 30d, or all", "7d")
42
+ .action(async (opts) => {
43
+ try {
44
+ const config = loadConfig();
45
+ const data = await fetchStats(config, opts.period);
46
+ console.log(formatStats(data, opts.period));
47
+ } catch (err: unknown) {
48
+ console.error(err instanceof Error ? err.message : String(err));
49
+ process.exit(1);
50
+ }
51
+ });
52
+
53
+ // bayane report
54
+ program
55
+ .command("report")
56
+ .description("Download a daily PDF report")
57
+ .option("--date <date>", "YYYY-MM-DD (defaults to latest available)")
58
+ .option("--latest", "Download the most recent available report")
59
+ .option("--output <path>", "Output file path")
60
+ .action(async (opts) => {
61
+ try {
62
+ const config = loadConfig();
63
+ const date =
64
+ opts.date ??
65
+ (opts.latest
66
+ ? await fetchLatestReportDate(config)
67
+ : new Date().toISOString().split("T")[0]);
68
+ const outPath = opts.output ?? `report-${date}.pdf`;
69
+ const buffer = await fetchReportPdf(config, date);
70
+ writeFileSync(outPath, buffer);
71
+ console.log(`✓ Report saved to ${outPath}`);
72
+ } catch (err: unknown) {
73
+ console.error(err instanceof Error ? err.message : String(err));
74
+ process.exit(1);
75
+ }
76
+ });
77
+
78
+ // bayane alerts
79
+ program
80
+ .command("alerts")
81
+ .description("Show critical alerts from the last 7 days")
82
+ .action(async () => {
83
+ try {
84
+ const config = loadConfig();
85
+ const data = await fetchStats(config, "7d");
86
+ console.log(formatAlerts(data));
87
+ } catch (err: unknown) {
88
+ console.error(err instanceof Error ? err.message : String(err));
89
+ process.exit(1);
90
+ }
91
+ });
92
+
93
+ // bayane install
94
+ program
95
+ .command("install")
96
+ .description("Install the bayane skill for your AI agent")
97
+ .action(() => {
98
+ console.log("Installing bayane skill...");
99
+ spawnSync("npx", ["skills", "add", "anouar-bm/bayane"], { stdio: "inherit" });
100
+ });
101
+
102
+ program.parse();
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { fetchStats, fetchReportPdf, fetchLatestReportDate } from "./api.js";
3
+
4
+ const mockConfig = { apiUrl: "https://test.example.com", apiKey: "sk_test" };
5
+
6
+ beforeEach(() => {
7
+ vi.resetAllMocks();
8
+ });
9
+
10
+ describe("fetchStats", () => {
11
+ it("throws on 401", async () => {
12
+ vi.stubGlobal("fetch", async () => ({ ok: false, status: 401, json: async () => ({}) }));
13
+ await expect(fetchStats(mockConfig, "7d")).rejects.toThrow("Invalid API key");
14
+ });
15
+
16
+ it("throws on network timeout", async () => {
17
+ vi.stubGlobal("fetch", () =>
18
+ new Promise((_, reject) => {
19
+ const err = new Error("aborted");
20
+ err.name = "AbortError";
21
+ setTimeout(() => reject(err), 0);
22
+ })
23
+ );
24
+ await expect(fetchStats(mockConfig, "7d")).rejects.toThrow("timed out");
25
+ });
26
+
27
+ it("returns data on success", async () => {
28
+ const mockData = {
29
+ totalSubmissions: 10,
30
+ averageScore: 8,
31
+ nps: 50,
32
+ serviceScores: [],
33
+ criticalAlerts: [],
34
+ };
35
+ vi.stubGlobal("fetch", async () => ({ ok: true, status: 200, json: async () => mockData }));
36
+ const result = await fetchStats(mockConfig, "7d");
37
+ expect(result.totalSubmissions).toBe(10);
38
+ });
39
+ });
40
+
41
+ describe("fetchReportPdf", () => {
42
+ it("throws on 401", async () => {
43
+ vi.stubGlobal("fetch", async () => ({ ok: false, status: 401, arrayBuffer: async () => new ArrayBuffer(0) }));
44
+ await expect(fetchReportPdf(mockConfig, "2026-03-22")).rejects.toThrow("Invalid API key");
45
+ });
46
+
47
+ it("throws on 404", async () => {
48
+ vi.stubGlobal("fetch", async () => ({ ok: false, status: 404 }));
49
+ await expect(fetchReportPdf(mockConfig, "2026-03-22")).rejects.toThrow("No report found");
50
+ });
51
+
52
+ it("returns buffer on success", async () => {
53
+ const bytes = new Uint8Array([1, 2, 3]);
54
+ vi.stubGlobal("fetch", async () => ({ ok: true, status: 200, arrayBuffer: async () => bytes.buffer }));
55
+ const buf = await fetchReportPdf(mockConfig, "2026-03-22");
56
+ expect(buf).toBeInstanceOf(Buffer);
57
+ expect(buf.length).toBe(3);
58
+ });
59
+ });
60
+
61
+ describe("fetchLatestReportDate", () => {
62
+ it("throws when no reports available", async () => {
63
+ vi.stubGlobal("fetch", async () => ({ ok: true, status: 200, json: async () => [] }));
64
+ await expect(fetchLatestReportDate(mockConfig)).rejects.toThrow("No reports available");
65
+ });
66
+
67
+ it("returns the first report date", async () => {
68
+ vi.stubGlobal("fetch", async () => ({
69
+ ok: true,
70
+ status: 200,
71
+ json: async () => [{ date: "2026-03-22" }, { date: "2026-03-15" }],
72
+ }));
73
+ const date = await fetchLatestReportDate(mockConfig);
74
+ expect(date).toBe("2026-03-22");
75
+ });
76
+ });
@@ -0,0 +1,65 @@
1
+ import { Config, AnalyticsData, Period } from "./types.js";
2
+
3
+ const TIMEOUT_MS = 10_000;
4
+
5
+ async function apiFetch<T>(config: Config, path: string): Promise<T> {
6
+ const controller = new AbortController();
7
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
8
+
9
+ try {
10
+ const res = await fetch(`${config.apiUrl}${path}`, {
11
+ headers: { Authorization: `Bearer ${config.apiKey}` },
12
+ signal: controller.signal,
13
+ });
14
+
15
+ if (res.status === 401) throw new Error("Invalid API key. Run: bayane setup");
16
+ if (!res.ok) {
17
+ const text = await res.text().catch(() => res.statusText);
18
+ throw new Error(`API error ${res.status}: ${text}`);
19
+ }
20
+
21
+ return res.json() as Promise<T>;
22
+ } catch (err: unknown) {
23
+ if (err instanceof Error && err.name === "AbortError") {
24
+ throw new Error("Request timed out. Check your connection.");
25
+ }
26
+ throw err;
27
+ } finally {
28
+ clearTimeout(timer);
29
+ }
30
+ }
31
+
32
+ export async function fetchStats(config: Config, period: Period): Promise<AnalyticsData> {
33
+ return apiFetch<AnalyticsData>(config, `/api/analytics?range=${period}`);
34
+ }
35
+
36
+ export async function fetchReportPdf(config: Config, date: string): Promise<Buffer> {
37
+ const controller = new AbortController();
38
+ const timer = setTimeout(() => controller.abort(), 30_000);
39
+
40
+ try {
41
+ const res = await fetch(`${config.apiUrl}/api/reports/${date}/pdf`, {
42
+ headers: { Authorization: `Bearer ${config.apiKey}` },
43
+ signal: controller.signal,
44
+ });
45
+
46
+ if (res.status === 401) throw new Error("Invalid API key. Run: bayane setup");
47
+ if (res.status === 404) throw new Error(`No report found for ${date}`);
48
+ if (!res.ok) throw new Error(`Failed to download report: ${res.status}`);
49
+
50
+ return Buffer.from(await res.arrayBuffer());
51
+ } catch (err: unknown) {
52
+ if (err instanceof Error && err.name === "AbortError") {
53
+ throw new Error("Report download timed out.");
54
+ }
55
+ throw err;
56
+ } finally {
57
+ clearTimeout(timer);
58
+ }
59
+ }
60
+
61
+ export async function fetchLatestReportDate(config: Config): Promise<string> {
62
+ const reports = await apiFetch<Array<{ date: string }>>(config, "/api/reports");
63
+ if (!reports.length) throw new Error("No reports available yet.");
64
+ return reports[0].date;
65
+ }
@@ -0,0 +1,23 @@
1
+ import { homedir } from "os";
2
+ import { join } from "path";
3
+ import { readFileSync, writeFileSync, existsSync } from "fs";
4
+ import { Config } from "./types.js";
5
+
6
+ export const CONFIG_PATH = join(homedir(), ".health-survey-rc");
7
+
8
+ export function loadConfig(): Config {
9
+ if (!existsSync(CONFIG_PATH)) {
10
+ console.error("Not configured. Run: bayane setup");
11
+ process.exit(1);
12
+ }
13
+ try {
14
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) as Config;
15
+ } catch {
16
+ console.error("Config file is corrupted. Run: bayane setup");
17
+ process.exit(1);
18
+ }
19
+ }
20
+
21
+ export function saveConfig(config: Config): void {
22
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
23
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { formatStats, formatAlerts } from "./format.js";
3
+ import { AnalyticsData } from "./types.js";
4
+
5
+ const mockData: AnalyticsData = {
6
+ totalSubmissions: 42,
7
+ averageScore: 7.8,
8
+ nps: 35,
9
+ serviceScores: [
10
+ { service: "Emergency", avgScore: 8.2, count: 20 },
11
+ { service: "Maternity", avgScore: 7.1, count: 22 },
12
+ ],
13
+ criticalAlerts: [],
14
+ };
15
+
16
+ describe("formatStats", () => {
17
+ it("shows key metrics", () => {
18
+ const out = formatStats(mockData, "7d");
19
+ expect(out).toContain("42");
20
+ expect(out).toContain("7.8/10");
21
+ expect(out).toContain("35");
22
+ });
23
+
24
+ it("sorts services by score descending", () => {
25
+ const out = formatStats(mockData, "7d");
26
+ expect(out.indexOf("Emergency")).toBeLessThan(out.indexOf("Maternity"));
27
+ });
28
+
29
+ it("shows alerts when present", () => {
30
+ const data = { ...mockData, criticalAlerts: [{ message: "Score below 5" }] };
31
+ expect(formatStats(data, "7d")).toContain("Score below 5");
32
+ });
33
+ });
34
+
35
+ describe("formatAlerts", () => {
36
+ it("returns ok message when no alerts", () => {
37
+ expect(formatAlerts(mockData)).toContain("No critical alerts");
38
+ });
39
+
40
+ it("lists alerts with service and date", () => {
41
+ const data = {
42
+ ...mockData,
43
+ criticalAlerts: [{ message: "Low score", service: "ER", date: "2026-03-21" }],
44
+ };
45
+ const out = formatAlerts(data);
46
+ expect(out).toContain("Low score");
47
+ expect(out).toContain("[ER]");
48
+ expect(out).toContain("2026-03-21");
49
+ });
50
+ });
@@ -0,0 +1,40 @@
1
+ import { AnalyticsData, Period } from "./types.js";
2
+
3
+ export function formatStats(data: AnalyticsData, period: Period): string {
4
+ const lines: string[] = [
5
+ ``,
6
+ `📊 Hospital Satisfaction — last ${period}`,
7
+ ` Responses: ${data.totalSubmissions}`,
8
+ ` Avg score: ${data.averageScore}/10`,
9
+ ` NPS: ${data.nps}`,
10
+ ];
11
+
12
+ if (data.serviceScores.length > 0) {
13
+ lines.push(``, ` By service:`);
14
+ [...data.serviceScores]
15
+ .sort((a, b) => b.avgScore - a.avgScore)
16
+ .forEach((s) =>
17
+ lines.push(` ${s.service.padEnd(20)} ${s.avgScore}/10 (${s.count} responses)`)
18
+ );
19
+ }
20
+
21
+ if (data.criticalAlerts.length > 0) {
22
+ lines.push(``, `⚠️ Critical alerts: ${data.criticalAlerts.length}`);
23
+ data.criticalAlerts.forEach((a) => lines.push(` - ${a.message}`));
24
+ }
25
+
26
+ return lines.join("\n");
27
+ }
28
+
29
+ export function formatAlerts(data: AnalyticsData): string {
30
+ if (data.criticalAlerts.length === 0) {
31
+ return "✓ No critical alerts in the last 7 days.";
32
+ }
33
+ const lines = [``, `⚠️ ${data.criticalAlerts.length} critical alert(s):`, ``];
34
+ data.criticalAlerts.forEach((a) => {
35
+ lines.push(
36
+ ` - ${a.message}${a.service ? ` [${a.service}]` : ""}${a.date ? ` — ${a.date}` : ""}`
37
+ );
38
+ });
39
+ return lines.join("\n");
40
+ }
@@ -0,0 +1,19 @@
1
+ export interface Config {
2
+ apiUrl: string;
3
+ apiKey: string;
4
+ }
5
+
6
+ export interface AnalyticsData {
7
+ totalSubmissions: number;
8
+ averageScore: number;
9
+ nps: number;
10
+ serviceScores: Array<{ service: string; avgScore: number; count: number }>;
11
+ criticalAlerts: Array<{ message: string; service?: string; date?: string }>;
12
+ }
13
+
14
+ export interface ReportMeta {
15
+ date: string;
16
+ url?: string;
17
+ }
18
+
19
+ export type Period = "7d" | "30d" | "all";
@@ -0,0 +1,76 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { z } from "zod";
4
+ import { writeFileSync } from "fs";
5
+ import { loadConfig } from "../core/config.js";
6
+ import { fetchStats, fetchReportPdf, fetchLatestReportDate } from "../core/api.js";
7
+ import { formatStats, formatAlerts } from "../core/format.js";
8
+
9
+ const server = new McpServer({
10
+ name: "health-survey",
11
+ version: "1.0.0",
12
+ });
13
+
14
+ server.tool(
15
+ "get_stats",
16
+ "Get hospital satisfaction statistics",
17
+ { period: z.enum(["7d", "30d", "all"]).default("7d").describe("Time period") },
18
+ async ({ period }) => {
19
+ const config = loadConfig();
20
+ const data = await fetchStats(config, period);
21
+ return { content: [{ type: "text", text: formatStats(data, period) }] };
22
+ }
23
+ );
24
+
25
+ server.tool(
26
+ "get_alerts",
27
+ "Get critical satisfaction alerts from the last 7 days",
28
+ {},
29
+ async () => {
30
+ const config = loadConfig();
31
+ const data = await fetchStats(config, "7d");
32
+ return { content: [{ type: "text", text: formatAlerts(data) }] };
33
+ }
34
+ );
35
+
36
+ server.tool(
37
+ "download_report",
38
+ "Download a hospital satisfaction PDF report",
39
+ {
40
+ date: z.string().optional().describe("YYYY-MM-DD — omit for latest"),
41
+ output: z.string().optional().describe("Output file path"),
42
+ },
43
+ async ({ date, output }) => {
44
+ const config = loadConfig();
45
+ const reportDate = date ?? (await fetchLatestReportDate(config));
46
+ const outPath = output ?? `report-${reportDate}.pdf`;
47
+ const buffer = await fetchReportPdf(config, reportDate);
48
+ writeFileSync(outPath, buffer);
49
+ return { content: [{ type: "text", text: `✓ Report saved to ${outPath}` }] };
50
+ }
51
+ );
52
+
53
+ server.tool(
54
+ "display_report",
55
+ "Display a hospital satisfaction PDF report (returns base64 for rendering)",
56
+ {
57
+ date: z.string().optional().describe("YYYY-MM-DD — omit for latest"),
58
+ },
59
+ async ({ date }) => {
60
+ const config = loadConfig();
61
+ const reportDate = date ?? (await fetchLatestReportDate(config));
62
+ const buffer = await fetchReportPdf(config, reportDate);
63
+ const base64 = buffer.toString("base64");
64
+ return {
65
+ content: [
66
+ {
67
+ type: "text",
68
+ text: `Hospital Satisfaction Report — ${reportDate}\n\nPDF (base64-encoded, ${buffer.length} bytes):\ndata:application/pdf;base64,${base64}`,
69
+ },
70
+ ],
71
+ };
72
+ }
73
+ );
74
+
75
+ const transport = new StdioServerTransport();
76
+ await server.connect(transport);
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true
11
+ },
12
+ "include": ["src"]
13
+ }