hymnkeep 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,11 @@
1
+ Copyright (c) 2026 R.J. Nestor. All rights reserved.
2
+
3
+ You may install and run this program to work with a Hymnkeep community you
4
+ belong to. You may not copy, change, publish, distribute, sublicense or sell
5
+ it, or any part of it, except as this paragraph allows.
6
+
7
+ THE PROGRAM IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
8
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
9
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
10
+ AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY ARISING FROM, OUT OF
11
+ OR IN CONNECTION WITH THE PROGRAM OR ITS USE.
package/README.md CHANGED
@@ -1,3 +1,74 @@
1
- # Temporary Holding Version
1
+ # hymnkeep
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Plan your Hymnkeep community from the command line, or let an AI app on your
4
+ computer do it for you.
5
+
6
+ ## Set up
7
+
8
+ You need [Node.js](https://nodejs.org) 20 or later, and a key from your
9
+ community's Keys page in Hymnkeep (Settings, then Keys). Only owners and
10
+ directors can make keys.
11
+
12
+ ```sh
13
+ npm install -g hymnkeep
14
+ hymnkeep login
15
+ ```
16
+
17
+ `login` asks for your community's address (paste the address of any Hymnkeep
18
+ page you have open) and the key, and keeps both on this computer.
19
+
20
+ ## Day to day
21
+
22
+ ```sh
23
+ hymnkeep week # the next occasion: its music and who is on it
24
+ hymnkeep week 2026-12-24 # a particular day
25
+ hymnkeep occasions # the next six weeks
26
+ hymnkeep find "gather us in" # music, with page numbers and when it was last sung
27
+
28
+ hymnkeep plan 2026-12-27 # plan a day from its templates
29
+ hymnkeep put "Gathering Hymn" "O Come, All Ye Faithful" --day 2026-12-27
30
+ hymnkeep put Psalm "Psalm 98" --at "Sun 8:00 AM" --preview
31
+ hymnkeep add-slot "Communion Meditation" --at "Sun 8:00 AM"
32
+ hymnkeep remove-slot Postlude --at Vigil
33
+
34
+ hymnkeep assign "Karis Wolfe" --job Cantor --day 2026-12-27
35
+ hymnkeep assign "Adult Choir" --at "Sun 11:00 AM"
36
+ hymnkeep unassign "Karis Wolfe" --at Vigil
37
+ hymnkeep copy --from "Sat 5:30 PM" --to "Sun 8:00 AM"
38
+ ```
39
+
40
+ A command acts on every service of the day unless `--at` names some.
41
+ `--preview` says what it would do and writes nothing. `--json` prints what
42
+ Hymnkeep sent, for scripts.
43
+
44
+ Everything else Hymnkeep can do is one call away: `hymnkeep api` lists the
45
+ calls, and `hymnkeep api <name> key=value …` makes one.
46
+
47
+ ## In an AI app
48
+
49
+ Add Hymnkeep as an MCP server, after `hymnkeep login`. In Claude Desktop,
50
+ Settings, then Developer, then Edit Config:
51
+
52
+ ```json
53
+ {
54
+ "mcpServers": {
55
+ "hymnkeep": { "command": "npx", "args": ["-y", "hymnkeep", "mcp"] }
56
+ }
57
+ }
58
+ ```
59
+
60
+ The app can then read your weekends, find music, plan days, put music in
61
+ place, and assign musicians. Anything that changes your plans is marked as a
62
+ change, so the app asks you first.
63
+
64
+ ## For scripts
65
+
66
+ `HYMNKEEP_KEY` and `HYMNKEEP_COMMUNITY` in the environment take the place of
67
+ `login`; `HYMNKEEP_URL` points it somewhere other than hymnkeep.com.
68
+
69
+ ## Keeping it in step with Hymnkeep
70
+
71
+ `openapi.json` is Hymnkeep's own description of its API, copied from
72
+ `api/openapi.json` in the Hymnkeep repository. When that file changes, copy it
73
+ here and release a new version: `hymnkeep api` and the MCP server's
74
+ `api_calls` read it as it ships.
package/dist/cli.js ADDED
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { createInterface } from 'node:readline/promises';
4
+ import { stdin, stdout } from 'node:process';
5
+ import { HymnkeepError, Hymnkeep } from './client.js';
6
+ import { communityFrom, forget, load, save } from './config.js';
7
+ import { serve } from './mcp.js';
8
+ import { call, operation, operations } from './operations.js';
9
+ import * as tasks from './tasks.js';
10
+ const program = new Command();
11
+ program
12
+ .name('hymnkeep')
13
+ .description('Plan Hymnkeep from the command line, or serve it to an AI app with `hymnkeep mcp`.')
14
+ .option('--json', 'print what Hymnkeep sent, as JSON')
15
+ .showHelpAfterError();
16
+ function api() {
17
+ const credentials = load();
18
+ if (!credentials) {
19
+ throw new Error('Not signed in. Run `hymnkeep login` first.');
20
+ }
21
+ return new Hymnkeep(credentials);
22
+ }
23
+ /** Prints a task's answer as sentences, or as JSON with --json. */
24
+ async function run(task) {
25
+ const result = await task();
26
+ stdout.write(`${program.opts().json ? JSON.stringify(result.data, null, 2) : result.text}\n`);
27
+ }
28
+ const collect = (value, previous = []) => [...previous, value];
29
+ /** Asks one question a line at a time, whether a person types the answers or a script pipes them. */
30
+ function questions() {
31
+ const lines = createInterface({ input: stdin, terminal: stdin.isTTY })[Symbol.asyncIterator]();
32
+ return {
33
+ async ask(question) {
34
+ stdout.write(question);
35
+ const next = await lines.next();
36
+ if (next.done) {
37
+ throw new Error('No answer given.');
38
+ }
39
+ return next.value.trim();
40
+ },
41
+ close: () => void lines.return?.(),
42
+ };
43
+ }
44
+ program
45
+ .command('login')
46
+ .description('Keep a key from your Keys page, and the community it belongs to, on this computer.')
47
+ .action(async () => {
48
+ const ask = questions();
49
+ const where = communityFrom(await ask.ask("Your community's address in Hymnkeep (paste any page's address): "));
50
+ const key = await ask.ask('The key from your Keys page: ');
51
+ ask.close();
52
+ const credentials = { ...where, key };
53
+ const me = await tasks.whoami(new Hymnkeep(credentials));
54
+ save(credentials);
55
+ stdout.write(`Signed in. ${me.text}\n`);
56
+ });
57
+ program.command('logout').description('Forget the key kept on this computer.').action(() => {
58
+ forget();
59
+ stdout.write('Forgotten.\n');
60
+ });
61
+ program.command('whoami').description('Who this key is, and what it may do.').action(() => run(() => tasks.whoami(api())));
62
+ program
63
+ .command('week')
64
+ .argument('[day]', 'a date (YYYY-MM-DD) or an occasion id; the next occasion if left out')
65
+ .description("An occasion's music, place by place, and who is on each service.")
66
+ .action((day) => run(() => tasks.show(api(), day)));
67
+ program
68
+ .command('occasions')
69
+ .description('What is planned in a date range: six weeks from today if left out.')
70
+ .option('--from <date>')
71
+ .option('--to <date>')
72
+ .action((o) => run(() => tasks.occasions(api(), o.from, o.to)));
73
+ program
74
+ .command('plan')
75
+ .argument('<date>', 'the day to plan, YYYY-MM-DD')
76
+ .description("Plan a day from its templates: shows what it would write and asks any question first.")
77
+ .option('--template <id>', 'a template to use; the ones the day ticks itself if left out', collect)
78
+ .option('--answer <key=value>', "an answer to one of the plan's questions", collect)
79
+ .option('--preview', 'show what it would write, and write nothing')
80
+ .option('--yes', 'write without asking')
81
+ .action(async (date, o) => {
82
+ const answers = Object.fromEntries((o.answer ?? []).map((a) => [a.slice(0, a.indexOf('=')), a.slice(a.indexOf('=') + 1)]));
83
+ const options = { date, templates: o.template, answers };
84
+ const look = await tasks.plan(api(), { ...options, preview: true });
85
+ if (o.preview || look.data.questions) {
86
+ stdout.write(`${program.opts().json ? JSON.stringify(look.data, null, 2) : look.text}\n`);
87
+ return;
88
+ }
89
+ if (!o.yes && stdin.isTTY) {
90
+ stdout.write(`${look.text.replace(/\n\nNothing written yet\.$/, '')}\n\n`);
91
+ const ask = questions();
92
+ const go = (await ask.ask('Write it? (y/N) ')).toLowerCase();
93
+ ask.close();
94
+ if (go !== 'y' && go !== 'yes') {
95
+ stdout.write('Nothing written.\n');
96
+ return;
97
+ }
98
+ }
99
+ await run(() => tasks.plan(api(), options));
100
+ });
101
+ program
102
+ .command('find')
103
+ .argument('<words...>', 'a title, a tune, a first line, a composer')
104
+ .description('Music in the library and the adopted hymnals.')
105
+ .option('--date <date>', 'page numbers as the books stand on this day')
106
+ .action((words, o) => run(() => tasks.find(api(), words.join(' '), o.date)));
107
+ const where = (command) => command
108
+ .option('--day <day>', 'a date or an occasion id; the next occasion if left out')
109
+ .option('--at <service>', 'a service by its time or name ("Sun 8:00 AM", "Vigil"); every service if left out', collect);
110
+ where(program
111
+ .command('put')
112
+ .argument('<slot>', 'the place, by name ("Gathering Hymn")')
113
+ .argument('<piece...>', 'the piece, by title or id')
114
+ .description('Put a piece in a place at every service named, replacing what the place held.')
115
+ .option('--both', 'where the place holds something else, sing both instead of replacing')
116
+ .option('--preview', 'say what it would do, and write nothing')).action((slot, piece, o) => run(() => tasks.put(api(), { when: o.day, slot, piece: piece.join(' '), services: o.at, both: o.both, preview: o.preview })));
117
+ where(program.command('add-slot').argument('<slot>', 'a slot name').description('Add a slot, empty, to every service named.')).action((slot, o) => run(() => tasks.addSlot(api(), { when: o.day, slot, services: o.at })));
118
+ where(program.command('remove-slot').argument('<slot>', 'a slot name').description('Take a slot off every service named.')).action((slot, o) => run(() => tasks.removeSlot(api(), { when: o.day, slot, services: o.at })));
119
+ where(program
120
+ .command('assign')
121
+ .argument('<who...>', 'a person or an ensemble, by name')
122
+ .description('Assign a person to a job, or an ensemble, at every service named.')
123
+ .option('--job <job>', 'the job, for a person ("Cantor")')
124
+ .option('--both', 'where someone else does the job, keep them too instead of replacing them')
125
+ .option('--preview', 'say what it would do, and write nothing')).action((who, o) => run(() => tasks.assign(api(), { when: o.day, who: who.join(' '), job: o.job, services: o.at, both: o.both, preview: o.preview })));
126
+ where(program
127
+ .command('unassign')
128
+ .argument('<who...>', 'a person or an ensemble, by name')
129
+ .description('Take a person or an ensemble off every service named.')
130
+ .option('--job <job>', 'only where they do this job')).action((who, o) => run(() => tasks.unassign(api(), { when: o.day, who: who.join(' '), job: o.job, services: o.at })));
131
+ program
132
+ .command('copy')
133
+ .description("Copy one service's music onto another of the same day.")
134
+ .requiredOption('--from <service>')
135
+ .requiredOption('--to <service>')
136
+ .option('--day <day>', 'a date or an occasion id; the next occasion if left out')
137
+ .option('--replace', "replace the second service's music rather than adding after it")
138
+ .action((o) => run(() => tasks.copy(api(), { when: o.day, from: o.from, to: o.to, replace: o.replace })));
139
+ program
140
+ .command('api')
141
+ .argument('[call]', 'a call by name; every call is listed if left out')
142
+ .argument('[params...]', 'name=value for each of its ids and fields')
143
+ .description('Any call the API offers, by name: the command-line door to everything.')
144
+ .option('--data <json>', 'the whole body of a write, as JSON')
145
+ .action(async (name, params, o) => {
146
+ if (!name) {
147
+ for (const op of operations()) {
148
+ stdout.write(`${op.id.padEnd(44)} ${op.method.padEnd(6)} ${op.summary.slice(0, 90)}\n`);
149
+ }
150
+ return;
151
+ }
152
+ const op = operation(name);
153
+ const values = o.data ? JSON.parse(o.data) : {};
154
+ for (const pair of params) {
155
+ const at = pair.indexOf('=');
156
+ values[pair.slice(0, at)] = pair.slice(at + 1);
157
+ }
158
+ stdout.write(`${JSON.stringify(await call(api(), op, values), null, 2)}\n`);
159
+ });
160
+ program
161
+ .command('mcp')
162
+ .description('Serve Hymnkeep to an AI app over standard input and output.')
163
+ .action(() => serve(api()));
164
+ program.parseAsync().catch((error) => {
165
+ process.stderr.write(`${error instanceof HymnkeepError ? error.explain() : error instanceof Error ? error.message : String(error)}\n`);
166
+ process.exitCode = 1;
167
+ });
package/dist/client.js ADDED
@@ -0,0 +1,85 @@
1
+ /** What Hymnkeep refused, in its own sentence, and the field-by-field reasons where it gave them. */
2
+ export class HymnkeepError extends Error {
3
+ status;
4
+ errors;
5
+ constructor(message, status, errors = {}) {
6
+ super(message);
7
+ this.status = status;
8
+ this.errors = errors;
9
+ }
10
+ /** The message with each field's reason under it. */
11
+ explain() {
12
+ const reasons = Object.values(this.errors).flat();
13
+ return [this.message, ...reasons.filter((reason) => reason !== this.message).map((reason) => ` ${reason}`)].join('\n');
14
+ }
15
+ }
16
+ /** The community API, reached with one person's key in one community. */
17
+ export class Hymnkeep {
18
+ credentials;
19
+ constructor(credentials) {
20
+ this.credentials = credentials;
21
+ }
22
+ get community() {
23
+ return this.credentials.community;
24
+ }
25
+ /** `path` is under `/api/p/{community}`, as `occasions` or `occasions/{id}`. */
26
+ async request(method, path, options = {}) {
27
+ const url = new URL(`${this.credentials.url}/api/p/${encodeURIComponent(this.credentials.community)}/${path.replace(/^\/+/, '')}`);
28
+ for (const [name, value] of Object.entries(options.query ?? {})) {
29
+ if (value === undefined || value === null) {
30
+ continue;
31
+ }
32
+ if (Array.isArray(value)) {
33
+ value.forEach((each) => url.searchParams.append(`${name}[]`, String(each)));
34
+ }
35
+ else {
36
+ url.searchParams.set(name, String(value));
37
+ }
38
+ }
39
+ const response = await fetch(url, {
40
+ method,
41
+ headers: {
42
+ Authorization: `Bearer ${this.credentials.key}`,
43
+ Accept: 'application/json',
44
+ ...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }),
45
+ 'User-Agent': 'hymnkeep-cli',
46
+ },
47
+ body: options.body === undefined ? undefined : JSON.stringify(options.body),
48
+ });
49
+ const text = await response.text();
50
+ const json = text === '' ? null : safeJson(text);
51
+ if (!response.ok) {
52
+ const body = (json ?? {});
53
+ throw new HymnkeepError(body.message || refusal(response.status), response.status, body.errors ?? {});
54
+ }
55
+ return json;
56
+ }
57
+ get(path, query) {
58
+ return this.request('GET', path, { query });
59
+ }
60
+ post(path, body) {
61
+ return this.request('POST', path, { body: body ?? {} });
62
+ }
63
+ patch(path, body) {
64
+ return this.request('PATCH', path, { body: body ?? {} });
65
+ }
66
+ delete(path) {
67
+ return this.request('DELETE', path);
68
+ }
69
+ }
70
+ function safeJson(text) {
71
+ try {
72
+ return JSON.parse(text);
73
+ }
74
+ catch {
75
+ return { message: text.slice(0, 300) };
76
+ }
77
+ }
78
+ function refusal(status) {
79
+ return ({
80
+ 401: 'That key is not valid for this community.',
81
+ 403: 'That key may not do this.',
82
+ 404: 'Nothing with that id in this community.',
83
+ 429: 'Too many calls at once. Wait a minute and try again.',
84
+ }[status] ?? `Hymnkeep answered ${status}.`);
85
+ }
package/dist/config.js ADDED
@@ -0,0 +1,73 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { homedir, platform } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ export const DEFAULT_URL = 'https://hymnkeep.com';
6
+ const SERVICE = 'hymnkeep';
7
+ function file() {
8
+ const base = platform() === 'win32' ? (process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming')) : (process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'));
9
+ return join(base, 'hymnkeep', 'credentials.json');
10
+ }
11
+ /** macOS keeps the key in the login keychain; elsewhere a file only its owner can read. */
12
+ const keychain = () => platform() === 'darwin' && process.env.HYMNKEEP_NO_KEYCHAIN === undefined;
13
+ /**
14
+ * The community's slug from what a person pastes: the slug itself, or the
15
+ * address of any Hymnkeep page in it ("https://hymnkeep.com/p/st-lukes/plan").
16
+ */
17
+ export function communityFrom(text) {
18
+ const trimmed = text.trim();
19
+ const match = trimmed.match(/^(https?:\/\/[^/]+)\/p\/([^/?#]+)/);
20
+ if (match) {
21
+ return { url: match[1], community: decodeURIComponent(match[2]) };
22
+ }
23
+ return { url: DEFAULT_URL, community: trimmed.replace(/^\/+|\/+$/g, '') };
24
+ }
25
+ export function load() {
26
+ if (process.env.HYMNKEEP_KEY && process.env.HYMNKEEP_COMMUNITY) {
27
+ return {
28
+ url: process.env.HYMNKEEP_URL ?? DEFAULT_URL,
29
+ community: process.env.HYMNKEEP_COMMUNITY,
30
+ key: process.env.HYMNKEEP_KEY,
31
+ };
32
+ }
33
+ if (!existsSync(file())) {
34
+ return null;
35
+ }
36
+ const saved = JSON.parse(readFileSync(file(), 'utf8'));
37
+ if (!saved.community) {
38
+ return null;
39
+ }
40
+ const key = keychain() ? readKeychain(saved.community) : saved.key;
41
+ return key ? { url: process.env.HYMNKEEP_URL ?? saved.url ?? DEFAULT_URL, community: saved.community, key } : null;
42
+ }
43
+ export function save(credentials) {
44
+ mkdirSync(dirname(file()), { recursive: true });
45
+ if (keychain()) {
46
+ execFileSync('security', ['add-generic-password', '-U', '-s', SERVICE, '-a', credentials.community, '-w', credentials.key]);
47
+ writeFileSync(file(), JSON.stringify({ url: credentials.url, community: credentials.community }, null, 2));
48
+ }
49
+ else {
50
+ writeFileSync(file(), JSON.stringify(credentials, null, 2), { mode: 0o600 });
51
+ chmodSync(file(), 0o600);
52
+ }
53
+ }
54
+ export function forget() {
55
+ const saved = load();
56
+ if (saved && keychain()) {
57
+ try {
58
+ execFileSync('security', ['delete-generic-password', '-s', SERVICE, '-a', saved.community], { stdio: 'ignore' });
59
+ }
60
+ catch {
61
+ // Already gone.
62
+ }
63
+ }
64
+ rmSync(file(), { force: true });
65
+ }
66
+ function readKeychain(account) {
67
+ try {
68
+ return execFileSync('security', ['find-generic-password', '-s', SERVICE, '-a', account, '-w'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ }
package/dist/mcp.js ADDED
@@ -0,0 +1,112 @@
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 { HymnkeepError } from './client.js';
5
+ import { call, operation, operations } from './operations.js';
6
+ import * as tasks from './tasks.js';
7
+ const day = z.string().optional().describe('A date (YYYY-MM-DD) or an occasion id. The next occasion if left out.');
8
+ const at = z.array(z.string()).optional().describe('Services by their time or name ("Sun 8:00 AM", "Vigil"). Every service of the day if left out.');
9
+ const preview = z.boolean().optional().describe('True to see what would happen without writing. Use it first, and show the person.');
10
+ const READ = { readOnlyHint: true, openWorldHint: false };
11
+ const WRITE = { readOnlyHint: false, destructiveHint: false, openWorldHint: false };
12
+ const REMOVE = { readOnlyHint: false, destructiveHint: true, openWorldHint: false };
13
+ const INSTRUCTIONS = `Hymnkeep plans the music and musicians of a community's services.
14
+ An occasion is a day (a Sunday, a feast); it has services (the Saturday vigil, Sunday 8:00, 11:00), each with places (slots) holding one piece each, and people assigned to jobs.
15
+ Start with "week" to see a day. A tool that writes has a preview: use it first, tell the person what will change, and write only once they agree.
16
+ Name services as Hymnkeep does ("Sun 8:00 AM"), never as "Mass".
17
+ For anything the day-to-day tools do not cover, "api_calls" lists every call and "api_call" makes one.`;
18
+ /** A task's answer as a tool's reply, its sentences first, with what was sent behind them. */
19
+ async function reply(task) {
20
+ try {
21
+ const result = await task();
22
+ return { content: [{ type: 'text', text: result.text }, { type: 'text', text: JSON.stringify(result.data) }] };
23
+ }
24
+ catch (error) {
25
+ const text = error instanceof HymnkeepError ? error.explain() : error instanceof Error ? error.message : String(error);
26
+ return { isError: true, content: [{ type: 'text', text }] };
27
+ }
28
+ }
29
+ export function build(api) {
30
+ const server = new McpServer({ name: 'hymnkeep', version: '0.1.0' }, { instructions: INSTRUCTIONS });
31
+ server.registerTool('whoami', { description: 'Who this key is: the community, the person, their role, and whether it may write.', annotations: READ }, () => reply(() => tasks.whoami(api)));
32
+ server.registerTool('week', { description: "An occasion's services, its music place by place (where services differ, each one's), and who is on each service.", inputSchema: { day }, annotations: READ }, ({ day: when }) => reply(() => tasks.show(api, when)));
33
+ server.registerTool('occasions', {
34
+ description: 'What is planned in a date range. Six weeks from today if left out.',
35
+ inputSchema: { from: z.string().optional(), to: z.string().optional() },
36
+ annotations: READ,
37
+ }, ({ from, to }) => reply(() => tasks.occasions(api, from, to)));
38
+ server.registerTool('find_music', {
39
+ description: 'Music in the library and the adopted hymnals, by title, tune, first line or composer, with page numbers and when it was last sung.',
40
+ inputSchema: { query: z.string(), date: z.string().optional().describe('Page numbers as the books stand on this day.') },
41
+ annotations: READ,
42
+ }, ({ query, date }) => reply(() => tasks.find(api, query, date)));
43
+ server.registerTool('plan_day', {
44
+ description: 'Plan a day from its templates. With preview true it says what it would write and lists any question the templates raise; answer each by its key in answers and call again. With preview false it writes.',
45
+ inputSchema: {
46
+ date: z.string().describe('YYYY-MM-DD'),
47
+ templates: z.array(z.string()).optional().describe('Template ids. The ones the day ticks itself if left out.'),
48
+ answers: z.record(z.string(), z.string()).optional().describe("Each question's key and the chosen option's value."),
49
+ preview,
50
+ },
51
+ annotations: WRITE,
52
+ }, ({ date, templates, answers, preview: look }) => reply(() => tasks.plan(api, { date, templates, answers, preview: look })));
53
+ server.registerTool('put_music', {
54
+ description: 'Put a piece in a place (slot) at the services named, replacing what the place held there, or singing both. A service without the place is given it.',
55
+ inputSchema: {
56
+ day,
57
+ slot: z.string().describe('The place, by name ("Gathering Hymn").'),
58
+ piece: z.string().describe('The piece, by title or id. find_music first when unsure.'),
59
+ services: at,
60
+ both: z.boolean().optional().describe('Where the place holds something else, sing both instead of replacing.'),
61
+ preview,
62
+ },
63
+ annotations: WRITE,
64
+ }, ({ day: when, slot, piece, services, both, preview: look }) => reply(() => tasks.put(api, { when, slot, piece, services, both, preview: look })));
65
+ server.registerTool('add_slot', {
66
+ description: 'Add a slot, empty, to the services named, at the end of each one\'s list.',
67
+ inputSchema: { day, slot: z.string().describe("A slot name from the community's list."), services: at },
68
+ annotations: WRITE,
69
+ }, ({ day: when, slot, services }) => reply(() => tasks.addSlot(api, { when, slot, services })));
70
+ server.registerTool('remove_slot', { description: 'Take a slot, and whatever is in it, off the services named.', inputSchema: { day, slot: z.string(), services: at }, annotations: REMOVE }, ({ day: when, slot, services }) => reply(() => tasks.removeSlot(api, { when, slot, services })));
71
+ server.registerTool('assign_musician', {
72
+ description: 'Assign a person to a job, or an ensemble, at the services named. Where someone else does that job, they are replaced unless both is true.',
73
+ inputSchema: {
74
+ day,
75
+ who: z.string().describe('A person or an ensemble, by name.'),
76
+ job: z.string().optional().describe('The job, for a person ("Cantor", "Organist").'),
77
+ services: at,
78
+ both: z.boolean().optional(),
79
+ preview,
80
+ },
81
+ annotations: WRITE,
82
+ }, ({ day: when, who, job, services, both, preview: look }) => reply(() => tasks.assign(api, { when, who, job, services, both, preview: look })));
83
+ server.registerTool('unassign_musician', { description: 'Take a person or an ensemble off the services named.', inputSchema: { day, who: z.string(), job: z.string().optional(), services: at }, annotations: REMOVE }, ({ day: when, who, job, services }) => reply(() => tasks.unassign(api, { when, who, job, services })));
84
+ server.registerTool('copy_music', {
85
+ description: "Copy one service's music onto another of the same day, after what it has, or replacing it.",
86
+ inputSchema: { day, from: z.string(), to: z.string(), replace: z.boolean().optional() },
87
+ annotations: WRITE,
88
+ }, ({ day: when, from, to, replace }) => reply(() => tasks.copy(api, { when, from, to, replace })));
89
+ server.registerTool('api_calls', {
90
+ description: 'Every call the Hymnkeep API offers, by name, for what the other tools do not cover. Filter by words in the name or summary.',
91
+ inputSchema: { search: z.string().optional() },
92
+ annotations: READ,
93
+ }, ({ search }) => reply(async () => {
94
+ const found = operations().filter((op) => !search || `${op.id} ${op.summary}`.toLowerCase().includes(search.toLowerCase()));
95
+ return {
96
+ data: found.map(({ id, method, path, summary, pathParams, queryParams, body }) => ({ id, method, path, summary, pathParams, queryParams, body })),
97
+ text: found.map((op) => `${op.id} (${op.method} ${op.path}): ${op.summary}`).join('\n') || 'No call matches.',
98
+ };
99
+ }));
100
+ server.registerTool('api_call', {
101
+ description: 'Make one API call by name (see api_calls), with its ids and fields in params. A call whose method is not GET writes: say what it will change first.',
102
+ inputSchema: { name: z.string(), params: z.record(z.string(), z.unknown()).optional() },
103
+ annotations: WRITE,
104
+ }, ({ name, params }) => reply(async () => {
105
+ const data = await call(api, operation(name), params ?? {});
106
+ return { data, text: `${name} done.` };
107
+ }));
108
+ return server;
109
+ }
110
+ export async function serve(api) {
111
+ await build(api).connect(new StdioServerTransport());
112
+ }
@@ -0,0 +1,52 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ const PREFIX = '/api/p/{community}';
5
+ let cached = null;
6
+ /** Every call, read from the description that ships beside the program. */
7
+ export function operations() {
8
+ if (cached) {
9
+ return cached;
10
+ }
11
+ const here = dirname(fileURLToPath(import.meta.url));
12
+ const document = JSON.parse(readFileSync(join(here, '..', 'openapi.json'), 'utf8'));
13
+ cached = Object.entries(document.paths).flatMap(([path, methods]) => Object.entries(methods).map(([method, op]) => ({
14
+ id: op.operationId,
15
+ method: method.toUpperCase(),
16
+ path: path.slice(PREFIX.length).replace(/^\//, ''),
17
+ summary: op.summary,
18
+ tag: op.tags[0] ?? '',
19
+ pathParams: (op.parameters ?? []).filter((p) => p.in === 'path' && p.name !== 'community').map((p) => p.name),
20
+ queryParams: (op.parameters ?? []).filter((p) => p.in === 'query').map((p) => ({ name: p.name.replace(/\[\]$/, ''), required: p.required, schema: p.schema })),
21
+ body: op.requestBody?.content['application/json'].schema ?? null,
22
+ writes: method !== 'get',
23
+ })));
24
+ return cached;
25
+ }
26
+ export function operation(id) {
27
+ const found = operations().find((op) => op.id === id);
28
+ if (!found) {
29
+ throw new Error(`No call named "${id}". \`hymnkeep api\` lists them.`);
30
+ }
31
+ return found;
32
+ }
33
+ /**
34
+ * Makes one call: the path's ids from `params`, the rest as the query of a
35
+ * read or the body of a write.
36
+ */
37
+ export async function call(api, op, params) {
38
+ const rest = { ...params };
39
+ let path = op.path;
40
+ for (const name of op.pathParams) {
41
+ const value = rest[name];
42
+ if (value === undefined || value === null || value === '') {
43
+ throw new Error(`"${op.id}" needs ${name}.`);
44
+ }
45
+ path = path.replace(`{${name}}`, encodeURIComponent(String(value)));
46
+ delete rest[name];
47
+ }
48
+ if (!op.writes || op.method === 'DELETE') {
49
+ return api.request(op.method, path, { query: rest });
50
+ }
51
+ return api.request(op.method, path, { body: rest });
52
+ }