@kivimedia/kmhub 2.9.1 → 2.11.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 +170 -170
- package/bin/kmhub.mjs +896 -896
- package/coach-book-output-guard.mjs +760 -760
- package/index.mjs +57 -57
- package/package.json +56 -56
- package/prompts/briefing.md +29 -29
- package/prompts/luxury.md +70 -70
- package/prompts/play.md +49 -49
- package/prompts/run.md +37 -36
- package/prompts/setup.md +33 -33
- package/prompts/vs-booked.md +46 -46
- package/prompts/what-can-you-do.md +40 -40
- package/prompts.mjs +110 -110
- package/read-only-tools.json +143 -142
- package/remote.mjs +929 -929
- package/tools/balloon-costing.mjs +80 -80
- package/tools/booking-equipment.mjs +110 -110
- package/tools/bridges.mjs +54 -54
- package/tools/briefing.mjs +91 -91
- package/tools/calendar.mjs +170 -170
- package/tools/capabilities.mjs +155 -155
- package/tools/catalog.mjs +288 -288
- package/tools/clubs.mjs +176 -176
- package/tools/coach.mjs +771 -771
- package/tools/compare.mjs +76 -76
- package/tools/core.mjs +244 -244
- package/tools/crm.mjs +209 -209
- package/tools/dubsado.mjs +137 -137
- package/tools/exports.mjs +128 -128
- package/tools/fact-review.mjs +125 -125
- package/tools/flows.mjs +261 -261
- package/tools/forms.mjs +158 -158
- package/tools/gols.mjs +144 -134
- package/tools/hr.mjs +162 -162
- package/tools/knowledge.mjs +129 -125
- package/tools/marketing.mjs +396 -396
- package/tools/meta.mjs +245 -245
- package/tools/military.mjs +244 -244
- package/tools/money.mjs +235 -197
- package/tools/outreach.mjs +238 -238
- package/tools/pending.mjs +122 -122
- package/tools/photos.mjs +140 -140
- package/tools/plays.mjs +244 -244
- package/tools/profile.mjs +118 -118
- package/tools/radar.mjs +173 -173
- package/tools/recurring-invoices.mjs +149 -149
- package/tools/reengage.mjs +434 -434
- package/tools/schedules.mjs +55 -55
- package/tools/setup.mjs +168 -168
- package/tools/sops-bridges.mjs +86 -86
- package/tools/sops.mjs +314 -314
- package/tools/sourcing.mjs +268 -268
- package/tools/strategy.mjs +146 -146
- package/tools/studio.mjs +132 -132
- package/tools/venueradar.mjs +151 -151
- package/tools/voice.mjs +137 -134
- package/tools.mjs +407 -407
package/tools/catalog.mjs
CHANGED
|
@@ -1,288 +1,288 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tool family: catalog - the public booking catalog tree, readable and editable.
|
|
3
|
-
*
|
|
4
|
-
* WHY THIS FAMILY EXISTS. On 01-Sep-2026 Mark Fuller asked his terminal to make a
|
|
5
|
-
* package appear under a second booking pathway and rename it there. The terminal
|
|
6
|
-
* read his whole catalog correctly and then had to hand him back to the mouse:
|
|
7
|
-
* km_get_offers_and_pricing returned a flat list with nothing saying which pathway
|
|
8
|
-
* each package sits under, and no tool could write a package at all. His written
|
|
9
|
-
* gap report asked for the mapping read, a package edit, a way to place a package
|
|
10
|
-
* on a pathway, the shared-or-copied design answer, and for every write to sit
|
|
11
|
-
* behind the confirm gate. This family is that list, in that order.
|
|
12
|
-
*
|
|
13
|
-
* THE DESIGN ANSWER his report demanded: a package lives under exactly ONE
|
|
14
|
-
* pathway (service_packages.pathway_id is a single reference, no join table).
|
|
15
|
-
* "Appear under a second pathway" is a COPY; renaming a copy never touches the
|
|
16
|
-
* original. The tools are named copy and move, not "attach", so the name cannot
|
|
17
|
-
* imply a data model the schema does not have.
|
|
18
|
-
*
|
|
19
|
-
* 🚨 THE BOOKING PAGE IS THE SHOPFRONT, a page on the open internet. Every write
|
|
20
|
-
* here is a confirmed write, same as form fields: KM Hub answers 409 with a plain
|
|
21
|
-
* sentence naming the package and pathway, and nothing happens until the person
|
|
22
|
-
* you are working with has said yes to that sentence.
|
|
23
|
-
*
|
|
24
|
-
* The family contract this file follows is documented in ./README.md.
|
|
25
|
-
*/
|
|
26
|
-
import { z } from 'zod';
|
|
27
|
-
|
|
28
|
-
export const FAMILY = 'catalog';
|
|
29
|
-
|
|
30
|
-
export const TOOLS = [
|
|
31
|
-
'km_get_catalog', 'km_update_package', 'km_copy_package_to_pathway', 'km_move_package_to_pathway',
|
|
32
|
-
'km_create_package', 'km_delete_package', 'km_get_package_composition', 'km_set_package_composition',
|
|
33
|
-
'km_create_pathway', 'km_update_pathway',
|
|
34
|
-
];
|
|
35
|
-
|
|
36
|
-
// Lands in `full` only. An EMPTY list means exactly that - ['*'] would opt into
|
|
37
|
-
// every profile, which is the mistake tools.mjs documents six families making.
|
|
38
|
-
export const PROFILES = [];
|
|
39
|
-
|
|
40
|
-
/** Route-level 404 (carries a routes list) vs a handler's not_found. See forms.mjs. */
|
|
41
|
-
function routeMissing(r) {
|
|
42
|
-
if (r.status === 405 || r.status === 501) return true;
|
|
43
|
-
return r.status === 404 && Array.isArray(r.data?.routes);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const NOT_SUPPORTED =
|
|
47
|
-
'This KM Hub is running a version that cannot read or edit the booking catalog tree from the terminal yet. The ' +
|
|
48
|
-
'workspace is fine and every other tool works as normal. The catalog is editable in the web app at ' +
|
|
49
|
-
'https://hub.kivimedia.co under Operations then Booking Catalogs.';
|
|
50
|
-
|
|
51
|
-
const CONFIRM_DESC =
|
|
52
|
-
'Leave this out on the first call. This action changes the public booking page, so it needs an explicit yes from '
|
|
53
|
-
+ 'the person you are working with: KM Hub answers 409 with a plain description of exactly what would change, plus '
|
|
54
|
-
+ 'a token. Show them that description in those words, wait for a real answer, and only then call again with the '
|
|
55
|
-
+ 'token and the SAME arguments. A yes for one change never authorises a different one.';
|
|
56
|
-
|
|
57
|
-
export function register(server, call, { out, text }) {
|
|
58
|
-
server.tool(
|
|
59
|
-
'km_get_catalog',
|
|
60
|
-
'The booking catalog the way a buyer sees it: pathways in display order, and which packages sit under each, ' +
|
|
61
|
-
'with price, duration, order and published state. This is the ONLY tool that shows the pathway-to-package ' +
|
|
62
|
-
'mapping, so reach for it before describing, editing, copying or moving anything on the public booking page; ' +
|
|
63
|
-
'the ids every write tool here needs come from it. ' +
|
|
64
|
-
'THE MODEL, so you never have to guess: a package lives under exactly ONE pathway. To show one under a ' +
|
|
65
|
-
'second pathway you COPY it there (km_copy_package_to_pathway); renaming the copy never touches the ' +
|
|
66
|
-
'original. A buyer sees a package only when both it and its pathway are published. Read only.',
|
|
67
|
-
{
|
|
68
|
-
catalog_id: z.string().optional().describe('From km_list_catalogs. Leave it out for the default catalog, which is almost always the one you want.'),
|
|
69
|
-
},
|
|
70
|
-
async ({ catalog_id }) => {
|
|
71
|
-
const q = catalog_id ? `?catalog_id=${encodeURIComponent(String(catalog_id).trim())}` : '';
|
|
72
|
-
const r = await call('GET', `/catalogs/tree${q}`);
|
|
73
|
-
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
74
|
-
return out(r);
|
|
75
|
-
},
|
|
76
|
-
);
|
|
77
|
-
|
|
78
|
-
server.tool(
|
|
79
|
-
'km_update_package',
|
|
80
|
-
'Edit one package on the public booking catalog: rename it, change its price or duration, edit its ' +
|
|
81
|
-
'description or badge, publish or unpublish it, or change its position in the pathway ' +
|
|
82
|
-
'(booking_display_seq, lowest first, which is also how you reorder). ' +
|
|
83
|
-
'Run km_get_catalog first and work from real ids. Send only the fields you are changing. ' +
|
|
84
|
-
'Prices are CENTS: base_price_cents 25000 is $250. The legacy proposal price is kept in step ' +
|
|
85
|
-
'automatically, so never try to set starting_price yourself. ' +
|
|
86
|
-
'🚨 booking_status is the PUBLIC switch: published means buyers see it on the booking page the moment you ' +
|
|
87
|
-
'save. This is a confirmed write; expect a 409 naming the package first.',
|
|
88
|
-
{
|
|
89
|
-
package_id: z.string().describe('UUID of the package, from km_get_catalog.'),
|
|
90
|
-
name: z.string().min(1).max(200).optional().describe('The name buyers read.'),
|
|
91
|
-
description: z.string().max(2000).nullable().optional().describe('Send null to clear it.'),
|
|
92
|
-
base_price_cents: z.number().int().min(0).nullable().optional().describe('The price the booking form charges, in cents. Null clears it.'),
|
|
93
|
-
duration_mins: z.number().int().min(0).nullable().optional(),
|
|
94
|
-
badge_text: z.string().max(200).nullable().optional().describe('The small badge on the package card, e.g. "Most popular". Null clears it.'),
|
|
95
|
-
per_child_pricing: z.boolean().optional().describe('True makes base_price_cents cover children_included children, with extras charged per child.'),
|
|
96
|
-
children_included: z.number().int().min(0).nullable().optional(),
|
|
97
|
-
price_per_extra_child_cents: z.number().int().min(0).nullable().optional(),
|
|
98
|
-
max_children: z.number().int().min(0).nullable().optional(),
|
|
99
|
-
booking_display_seq: z.number().int().min(0).optional().describe('Position inside its pathway, lowest first. Set these across siblings to reorder.'),
|
|
100
|
-
booking_status: z.enum(['draft', 'published']).optional().describe('published puts it on the public page. draft takes it off.'),
|
|
101
|
-
confirm_token: z.string().optional().describe(CONFIRM_DESC),
|
|
102
|
-
},
|
|
103
|
-
async ({ package_id, ...changes }) => {
|
|
104
|
-
const r = await call('PATCH', `/packages/${encodeURIComponent(String(package_id || '').trim())}`, changes);
|
|
105
|
-
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
106
|
-
return out(r);
|
|
107
|
-
},
|
|
108
|
-
);
|
|
109
|
-
|
|
110
|
-
server.tool(
|
|
111
|
-
'km_copy_package_to_pathway',
|
|
112
|
-
'Clone a package into another pathway, landing at the bottom of that pathway, with its add-on options and ' +
|
|
113
|
-
'choices copied along. This is HOW a package appears under a second pathway: a package lives under exactly ' +
|
|
114
|
-
'one, so a second appearance is a copy, and renaming the copy (new_name) never touches the original. ' +
|
|
115
|
-
'The copy keeps the original\'s booking_status, so copying a published package into a published pathway is ' +
|
|
116
|
-
'live immediately - the confirmation sentence says which you are getting. ' +
|
|
117
|
-
'After copying, edits to either one are separate: a price change on the original does NOT follow to the ' +
|
|
118
|
-
'copy, which is worth saying to the owner out loud. This is a confirmed write; expect a 409 first.',
|
|
119
|
-
{
|
|
120
|
-
package_id: z.string().describe('UUID of the package to copy, from km_get_catalog.'),
|
|
121
|
-
pathway_id: z.string().describe('UUID of the pathway to copy it into, from km_get_catalog.'),
|
|
122
|
-
new_name: z.string().min(1).max(200).optional().describe('What the copy is called. Leave it out to keep the same name.'),
|
|
123
|
-
confirm_token: z.string().optional().describe(CONFIRM_DESC),
|
|
124
|
-
},
|
|
125
|
-
async ({ package_id, ...body }) => {
|
|
126
|
-
const r = await call('POST', `/packages/${encodeURIComponent(String(package_id || '').trim())}/copy`, body);
|
|
127
|
-
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
128
|
-
return out(r);
|
|
129
|
-
},
|
|
130
|
-
);
|
|
131
|
-
|
|
132
|
-
server.tool(
|
|
133
|
-
'km_move_package_to_pathway',
|
|
134
|
-
'Move a package to a different pathway, landing at the bottom, optionally renaming it on the way. ' +
|
|
135
|
-
'It LEAVES its current pathway: a package lives under exactly one, so a move is a removal there and an ' +
|
|
136
|
-
'arrival here, in one step. When the owner wants it in BOTH places, use km_copy_package_to_pathway ' +
|
|
137
|
-
'instead. This is a confirmed write; expect a 409 naming both pathways first.',
|
|
138
|
-
{
|
|
139
|
-
package_id: z.string().describe('UUID of the package to move, from km_get_catalog.'),
|
|
140
|
-
pathway_id: z.string().describe('UUID of the destination pathway, from km_get_catalog.'),
|
|
141
|
-
new_name: z.string().min(1).max(200).optional().describe('Rename it as part of the move. Leave it out to keep the name.'),
|
|
142
|
-
confirm_token: z.string().optional().describe(CONFIRM_DESC),
|
|
143
|
-
},
|
|
144
|
-
async ({ package_id, ...body }) => {
|
|
145
|
-
const r = await call('POST', `/packages/${encodeURIComponent(String(package_id || '').trim())}/move`, body);
|
|
146
|
-
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
147
|
-
return out(r);
|
|
148
|
-
},
|
|
149
|
-
);
|
|
150
|
-
|
|
151
|
-
server.tool(
|
|
152
|
-
'km_create_pathway',
|
|
153
|
-
'Create a booking pathway: the CATEGORY that packages live under on the public booking page, like ' +
|
|
154
|
-
'"Birthday Parties" or "Monthly Subscriptions". Reach for this FIRST when building a booking page from ' +
|
|
155
|
-
'nothing, because km_create_package requires a pathway_id and a workspace that has never opened the ' +
|
|
156
|
-
'Catalog Builder has no pathway to give it - that is exactly the dead end this tool removes. ' +
|
|
157
|
-
'It is a DRAFT unless you say otherwise, so nothing reaches buyers by surprise. ' +
|
|
158
|
-
'catalog_id is optional: left out, it uses the workspace default catalog, and if the workspace has NO ' +
|
|
159
|
-
'catalog at all it creates one (Main Catalog) on the way, so this works on a completely empty workspace. ' +
|
|
160
|
-
'Run km_get_catalog afterwards to see the new pathway with its id, then create packages under it. ' +
|
|
161
|
-
'This is a confirmed write; expect a 409 naming the pathway and catalog first.',
|
|
162
|
-
{
|
|
163
|
-
name: z.string().min(1).max(200).describe('The category name buyers read, e.g. "Dazzling Decor Club".'),
|
|
164
|
-
catalog_id: z.string().optional().describe('From km_list_catalogs. Leave it out for the default catalog, which is almost always right - and which will be created if the workspace has none.'),
|
|
165
|
-
subtitle: z.string().max(300).nullable().optional().describe('The small line under the name on the booking page, e.g. "Billed monthly, 3-month minimum".'),
|
|
166
|
-
description: z.string().max(2000).nullable().optional(),
|
|
167
|
-
status: z.enum(['draft', 'published']).optional().describe('Leave it out for draft, the safe default. published puts the category on the public page - but buyers still see nothing under it until its packages are published too.'),
|
|
168
|
-
image_url: z.string().max(1000).nullable().optional().describe('An image for the category card. A public URL; this tool does not upload anything.'),
|
|
169
|
-
display_seq: z.number().int().min(0).optional().describe('Position among the pathways, lowest first. Leave it out to land at the bottom.'),
|
|
170
|
-
confirm_token: z.string().optional().describe(CONFIRM_DESC),
|
|
171
|
-
},
|
|
172
|
-
async (body) => {
|
|
173
|
-
const r = await call('POST', '/pathways', body);
|
|
174
|
-
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
175
|
-
return out(r);
|
|
176
|
-
},
|
|
177
|
-
);
|
|
178
|
-
|
|
179
|
-
server.tool(
|
|
180
|
-
'km_update_pathway',
|
|
181
|
-
'Edit one booking pathway (the category packages sit under): rename it, change its subtitle or description, ' +
|
|
182
|
-
'publish or unpublish it, or move its position among the pathways (display_seq, lowest first). ' +
|
|
183
|
-
'Run km_get_catalog first and work from real ids. Send only the fields you are changing. ' +
|
|
184
|
-
'🚨 status is the PUBLIC switch, and it is only HALF the switch: a buyer sees a package only when the ' +
|
|
185
|
-
'PACKAGE is published AND its pathway is published. Publishing a pathway whose packages are all drafts ' +
|
|
186
|
-
'shows buyers an empty category; unpublishing a pathway hides every package under it at once, however ' +
|
|
187
|
-
'published those packages are. The response says which of those you just did. ' +
|
|
188
|
-
'This is a confirmed write; expect a 409 naming the pathway first.',
|
|
189
|
-
{
|
|
190
|
-
pathway_id: z.string().describe('UUID of the pathway, from km_get_catalog.'),
|
|
191
|
-
name: z.string().min(1).max(200).optional().describe('The category name buyers read.'),
|
|
192
|
-
subtitle: z.string().max(300).nullable().optional().describe('The small line under the name. Send null to clear it.'),
|
|
193
|
-
description: z.string().max(2000).nullable().optional().describe('Send null to clear it.'),
|
|
194
|
-
status: z.enum(['draft', 'published']).optional().describe('published puts the category on the public page. draft takes it, and everything under it, off.'),
|
|
195
|
-
display_seq: z.number().int().min(0).optional().describe('Position among the pathways, lowest first. Set these across siblings to reorder.'),
|
|
196
|
-
confirm_token: z.string().optional().describe(CONFIRM_DESC),
|
|
197
|
-
},
|
|
198
|
-
async ({ pathway_id, ...changes }) => {
|
|
199
|
-
const r = await call('PATCH', `/pathways/${encodeURIComponent(String(pathway_id || '').trim())}`, changes);
|
|
200
|
-
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
201
|
-
return out(r);
|
|
202
|
-
},
|
|
203
|
-
);
|
|
204
|
-
|
|
205
|
-
server.tool(
|
|
206
|
-
'km_create_package',
|
|
207
|
-
'Create a new package on the public booking catalog, at the bottom of a pathway. It is a DRAFT unless you ' +
|
|
208
|
-
'say otherwise, so nothing reaches buyers by surprise. Run km_get_catalog first for the pathway id; if the ' +
|
|
209
|
-
'catalog is empty and there is no pathway to put this under, make one with km_create_pathway. ' +
|
|
210
|
-
'Price is CENTS: 36500 is $365; the legacy proposal price is kept in step automatically. ' +
|
|
211
|
-
'After creating, say what the package is MADE OF with km_set_package_composition - that is what lets ' +
|
|
212
|
-
'quotes offer it as editable service lines and shows the owner the discount they are giving. ' +
|
|
213
|
-
'This is a confirmed write; expect a 409 naming the package and pathway first.',
|
|
214
|
-
{
|
|
215
|
-
pathway_id: z.string().describe('UUID of the pathway it lives under, from km_get_catalog.'),
|
|
216
|
-
name: z.string().min(1).max(200).describe('The name buyers read.'),
|
|
217
|
-
base_price_cents: z.number().int().min(0).nullable().optional().describe('The price the booking form charges, in cents. Leave it out for no price yet.'),
|
|
218
|
-
description: z.string().max(2000).nullable().optional(),
|
|
219
|
-
booking_status: z.enum(['draft', 'published']).optional().describe('Leave it out for draft, the safe default. published shows it to buyers the moment the pathway is also published.'),
|
|
220
|
-
confirm_token: z.string().optional().describe(CONFIRM_DESC),
|
|
221
|
-
},
|
|
222
|
-
async (body) => {
|
|
223
|
-
const r = await call('POST', '/packages', body);
|
|
224
|
-
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
225
|
-
return out(r);
|
|
226
|
-
},
|
|
227
|
-
);
|
|
228
|
-
|
|
229
|
-
server.tool(
|
|
230
|
-
'km_delete_package',
|
|
231
|
-
'Delete a package from the booking catalog, permanently. Its composition links and add-on options are ' +
|
|
232
|
-
'deleted with it, and there is no undo - which is why unpublishing (km_update_package with ' +
|
|
233
|
-
"booking_status 'draft') is usually the better first move, and this tool is for packages that are truly " +
|
|
234
|
-
'wrong, duplicates, or test rows. If bookings or quotes reference the package the delete is refused, and ' +
|
|
235
|
-
'unpublishing is the answer. This is a confirmed write; expect a 409 naming the package first.',
|
|
236
|
-
{
|
|
237
|
-
package_id: z.string().describe('UUID of the package, from km_get_catalog.'),
|
|
238
|
-
confirm_token: z.string().optional().describe(CONFIRM_DESC),
|
|
239
|
-
},
|
|
240
|
-
async ({ package_id, ...body }) => {
|
|
241
|
-
const r = await call('DELETE', `/packages/${encodeURIComponent(String(package_id || '').trim())}`, body);
|
|
242
|
-
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
243
|
-
return out(r);
|
|
244
|
-
},
|
|
245
|
-
);
|
|
246
|
-
|
|
247
|
-
server.tool(
|
|
248
|
-
'km_get_package_composition',
|
|
249
|
-
'What a package is MADE OF: the services inside it with quantities, each priced LIVE off the rate card, ' +
|
|
250
|
-
'plus the parts total and the discount against the package price. A package is an assortment of services ' +
|
|
251
|
-
'the OWNER selected - "3 hours of Balloon Twisting + 1 x 30-minute Magic Show" - never everything on the ' +
|
|
252
|
-
'rate card. The response also carries available_services (the whole rate card with ids), so this one call ' +
|
|
253
|
-
'gives you everything km_set_package_composition needs. An empty composition is a fine state: the package ' +
|
|
254
|
-
'sells on its price alone. Read only.',
|
|
255
|
-
{
|
|
256
|
-
package_id: z.string().describe('UUID of the package, from km_get_catalog.'),
|
|
257
|
-
},
|
|
258
|
-
async ({ package_id }) => {
|
|
259
|
-
const r = await call('GET', `/packages/${encodeURIComponent(String(package_id || '').trim())}/composition`);
|
|
260
|
-
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
261
|
-
return out(r);
|
|
262
|
-
},
|
|
263
|
-
);
|
|
264
|
-
|
|
265
|
-
server.tool(
|
|
266
|
-
'km_set_package_composition',
|
|
267
|
-
'Set what a package is made of, in ONE call: the full list of services and how much of each. Whatever you ' +
|
|
268
|
-
'send REPLACES the current composition, so start from km_get_package_composition and send the complete ' +
|
|
269
|
-
'list, not just the change. quantity is in the service\'s own unit: a per-hour service with quantity 3 ' +
|
|
270
|
-
'means 3 hours; decimals like 1.5 are fine. An empty list clears the composition (the package keeps its ' +
|
|
271
|
-
'price and stays bookable). Composition never changes what a buyer pays - the package price stands; it is ' +
|
|
272
|
-
'what lets quotes explode a package into editable service lines and shows the owner the discount they are ' +
|
|
273
|
-
'giving off their own rates. This is a confirmed write; expect a 409 spelling the composition out first.',
|
|
274
|
-
{
|
|
275
|
-
package_id: z.string().describe('UUID of the package, from km_get_catalog.'),
|
|
276
|
-
services: z.array(z.object({
|
|
277
|
-
service_id: z.string().describe('UUID of a rate-card service, from available_services on km_get_package_composition.'),
|
|
278
|
-
quantity: z.number().gt(0).max(999).describe("How much of it, in the service's own unit. 3 on a per-hour service = 3 hours."),
|
|
279
|
-
})).max(50).describe('The COMPLETE composition. It replaces what is there. Empty array clears it.'),
|
|
280
|
-
confirm_token: z.string().optional().describe(CONFIRM_DESC),
|
|
281
|
-
},
|
|
282
|
-
async ({ package_id, ...body }) => {
|
|
283
|
-
const r = await call('PUT', `/packages/${encodeURIComponent(String(package_id || '').trim())}/composition`, body);
|
|
284
|
-
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
285
|
-
return out(r);
|
|
286
|
-
},
|
|
287
|
-
);
|
|
288
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Tool family: catalog - the public booking catalog tree, readable and editable.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS FAMILY EXISTS. On 01-Sep-2026 Mark Fuller asked his terminal to make a
|
|
5
|
+
* package appear under a second booking pathway and rename it there. The terminal
|
|
6
|
+
* read his whole catalog correctly and then had to hand him back to the mouse:
|
|
7
|
+
* km_get_offers_and_pricing returned a flat list with nothing saying which pathway
|
|
8
|
+
* each package sits under, and no tool could write a package at all. His written
|
|
9
|
+
* gap report asked for the mapping read, a package edit, a way to place a package
|
|
10
|
+
* on a pathway, the shared-or-copied design answer, and for every write to sit
|
|
11
|
+
* behind the confirm gate. This family is that list, in that order.
|
|
12
|
+
*
|
|
13
|
+
* THE DESIGN ANSWER his report demanded: a package lives under exactly ONE
|
|
14
|
+
* pathway (service_packages.pathway_id is a single reference, no join table).
|
|
15
|
+
* "Appear under a second pathway" is a COPY; renaming a copy never touches the
|
|
16
|
+
* original. The tools are named copy and move, not "attach", so the name cannot
|
|
17
|
+
* imply a data model the schema does not have.
|
|
18
|
+
*
|
|
19
|
+
* 🚨 THE BOOKING PAGE IS THE SHOPFRONT, a page on the open internet. Every write
|
|
20
|
+
* here is a confirmed write, same as form fields: KM Hub answers 409 with a plain
|
|
21
|
+
* sentence naming the package and pathway, and nothing happens until the person
|
|
22
|
+
* you are working with has said yes to that sentence.
|
|
23
|
+
*
|
|
24
|
+
* The family contract this file follows is documented in ./README.md.
|
|
25
|
+
*/
|
|
26
|
+
import { z } from 'zod';
|
|
27
|
+
|
|
28
|
+
export const FAMILY = 'catalog';
|
|
29
|
+
|
|
30
|
+
export const TOOLS = [
|
|
31
|
+
'km_get_catalog', 'km_update_package', 'km_copy_package_to_pathway', 'km_move_package_to_pathway',
|
|
32
|
+
'km_create_package', 'km_delete_package', 'km_get_package_composition', 'km_set_package_composition',
|
|
33
|
+
'km_create_pathway', 'km_update_pathway',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// Lands in `full` only. An EMPTY list means exactly that - ['*'] would opt into
|
|
37
|
+
// every profile, which is the mistake tools.mjs documents six families making.
|
|
38
|
+
export const PROFILES = [];
|
|
39
|
+
|
|
40
|
+
/** Route-level 404 (carries a routes list) vs a handler's not_found. See forms.mjs. */
|
|
41
|
+
function routeMissing(r) {
|
|
42
|
+
if (r.status === 405 || r.status === 501) return true;
|
|
43
|
+
return r.status === 404 && Array.isArray(r.data?.routes);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const NOT_SUPPORTED =
|
|
47
|
+
'This KM Hub is running a version that cannot read or edit the booking catalog tree from the terminal yet. The ' +
|
|
48
|
+
'workspace is fine and every other tool works as normal. The catalog is editable in the web app at ' +
|
|
49
|
+
'https://hub.kivimedia.co under Operations then Booking Catalogs.';
|
|
50
|
+
|
|
51
|
+
const CONFIRM_DESC =
|
|
52
|
+
'Leave this out on the first call. This action changes the public booking page, so it needs an explicit yes from '
|
|
53
|
+
+ 'the person you are working with: KM Hub answers 409 with a plain description of exactly what would change, plus '
|
|
54
|
+
+ 'a token. Show them that description in those words, wait for a real answer, and only then call again with the '
|
|
55
|
+
+ 'token and the SAME arguments. A yes for one change never authorises a different one.';
|
|
56
|
+
|
|
57
|
+
export function register(server, call, { out, text }) {
|
|
58
|
+
server.tool(
|
|
59
|
+
'km_get_catalog',
|
|
60
|
+
'The booking catalog the way a buyer sees it: pathways in display order, and which packages sit under each, ' +
|
|
61
|
+
'with price, duration, order and published state. This is the ONLY tool that shows the pathway-to-package ' +
|
|
62
|
+
'mapping, so reach for it before describing, editing, copying or moving anything on the public booking page; ' +
|
|
63
|
+
'the ids every write tool here needs come from it. ' +
|
|
64
|
+
'THE MODEL, so you never have to guess: a package lives under exactly ONE pathway. To show one under a ' +
|
|
65
|
+
'second pathway you COPY it there (km_copy_package_to_pathway); renaming the copy never touches the ' +
|
|
66
|
+
'original. A buyer sees a package only when both it and its pathway are published. Read only.',
|
|
67
|
+
{
|
|
68
|
+
catalog_id: z.string().optional().describe('From km_list_catalogs. Leave it out for the default catalog, which is almost always the one you want.'),
|
|
69
|
+
},
|
|
70
|
+
async ({ catalog_id }) => {
|
|
71
|
+
const q = catalog_id ? `?catalog_id=${encodeURIComponent(String(catalog_id).trim())}` : '';
|
|
72
|
+
const r = await call('GET', `/catalogs/tree${q}`);
|
|
73
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
74
|
+
return out(r);
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
server.tool(
|
|
79
|
+
'km_update_package',
|
|
80
|
+
'Edit one package on the public booking catalog: rename it, change its price or duration, edit its ' +
|
|
81
|
+
'description or badge, publish or unpublish it, or change its position in the pathway ' +
|
|
82
|
+
'(booking_display_seq, lowest first, which is also how you reorder). ' +
|
|
83
|
+
'Run km_get_catalog first and work from real ids. Send only the fields you are changing. ' +
|
|
84
|
+
'Prices are CENTS: base_price_cents 25000 is $250. The legacy proposal price is kept in step ' +
|
|
85
|
+
'automatically, so never try to set starting_price yourself. ' +
|
|
86
|
+
'🚨 booking_status is the PUBLIC switch: published means buyers see it on the booking page the moment you ' +
|
|
87
|
+
'save. This is a confirmed write; expect a 409 naming the package first.',
|
|
88
|
+
{
|
|
89
|
+
package_id: z.string().describe('UUID of the package, from km_get_catalog.'),
|
|
90
|
+
name: z.string().min(1).max(200).optional().describe('The name buyers read.'),
|
|
91
|
+
description: z.string().max(2000).nullable().optional().describe('Send null to clear it.'),
|
|
92
|
+
base_price_cents: z.number().int().min(0).nullable().optional().describe('The price the booking form charges, in cents. Null clears it.'),
|
|
93
|
+
duration_mins: z.number().int().min(0).nullable().optional(),
|
|
94
|
+
badge_text: z.string().max(200).nullable().optional().describe('The small badge on the package card, e.g. "Most popular". Null clears it.'),
|
|
95
|
+
per_child_pricing: z.boolean().optional().describe('True makes base_price_cents cover children_included children, with extras charged per child.'),
|
|
96
|
+
children_included: z.number().int().min(0).nullable().optional(),
|
|
97
|
+
price_per_extra_child_cents: z.number().int().min(0).nullable().optional(),
|
|
98
|
+
max_children: z.number().int().min(0).nullable().optional(),
|
|
99
|
+
booking_display_seq: z.number().int().min(0).optional().describe('Position inside its pathway, lowest first. Set these across siblings to reorder.'),
|
|
100
|
+
booking_status: z.enum(['draft', 'published']).optional().describe('published puts it on the public page. draft takes it off.'),
|
|
101
|
+
confirm_token: z.string().optional().describe(CONFIRM_DESC),
|
|
102
|
+
},
|
|
103
|
+
async ({ package_id, ...changes }) => {
|
|
104
|
+
const r = await call('PATCH', `/packages/${encodeURIComponent(String(package_id || '').trim())}`, changes);
|
|
105
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
106
|
+
return out(r);
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
server.tool(
|
|
111
|
+
'km_copy_package_to_pathway',
|
|
112
|
+
'Clone a package into another pathway, landing at the bottom of that pathway, with its add-on options and ' +
|
|
113
|
+
'choices copied along. This is HOW a package appears under a second pathway: a package lives under exactly ' +
|
|
114
|
+
'one, so a second appearance is a copy, and renaming the copy (new_name) never touches the original. ' +
|
|
115
|
+
'The copy keeps the original\'s booking_status, so copying a published package into a published pathway is ' +
|
|
116
|
+
'live immediately - the confirmation sentence says which you are getting. ' +
|
|
117
|
+
'After copying, edits to either one are separate: a price change on the original does NOT follow to the ' +
|
|
118
|
+
'copy, which is worth saying to the owner out loud. This is a confirmed write; expect a 409 first.',
|
|
119
|
+
{
|
|
120
|
+
package_id: z.string().describe('UUID of the package to copy, from km_get_catalog.'),
|
|
121
|
+
pathway_id: z.string().describe('UUID of the pathway to copy it into, from km_get_catalog.'),
|
|
122
|
+
new_name: z.string().min(1).max(200).optional().describe('What the copy is called. Leave it out to keep the same name.'),
|
|
123
|
+
confirm_token: z.string().optional().describe(CONFIRM_DESC),
|
|
124
|
+
},
|
|
125
|
+
async ({ package_id, ...body }) => {
|
|
126
|
+
const r = await call('POST', `/packages/${encodeURIComponent(String(package_id || '').trim())}/copy`, body);
|
|
127
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
128
|
+
return out(r);
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
server.tool(
|
|
133
|
+
'km_move_package_to_pathway',
|
|
134
|
+
'Move a package to a different pathway, landing at the bottom, optionally renaming it on the way. ' +
|
|
135
|
+
'It LEAVES its current pathway: a package lives under exactly one, so a move is a removal there and an ' +
|
|
136
|
+
'arrival here, in one step. When the owner wants it in BOTH places, use km_copy_package_to_pathway ' +
|
|
137
|
+
'instead. This is a confirmed write; expect a 409 naming both pathways first.',
|
|
138
|
+
{
|
|
139
|
+
package_id: z.string().describe('UUID of the package to move, from km_get_catalog.'),
|
|
140
|
+
pathway_id: z.string().describe('UUID of the destination pathway, from km_get_catalog.'),
|
|
141
|
+
new_name: z.string().min(1).max(200).optional().describe('Rename it as part of the move. Leave it out to keep the name.'),
|
|
142
|
+
confirm_token: z.string().optional().describe(CONFIRM_DESC),
|
|
143
|
+
},
|
|
144
|
+
async ({ package_id, ...body }) => {
|
|
145
|
+
const r = await call('POST', `/packages/${encodeURIComponent(String(package_id || '').trim())}/move`, body);
|
|
146
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
147
|
+
return out(r);
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
server.tool(
|
|
152
|
+
'km_create_pathway',
|
|
153
|
+
'Create a booking pathway: the CATEGORY that packages live under on the public booking page, like ' +
|
|
154
|
+
'"Birthday Parties" or "Monthly Subscriptions". Reach for this FIRST when building a booking page from ' +
|
|
155
|
+
'nothing, because km_create_package requires a pathway_id and a workspace that has never opened the ' +
|
|
156
|
+
'Catalog Builder has no pathway to give it - that is exactly the dead end this tool removes. ' +
|
|
157
|
+
'It is a DRAFT unless you say otherwise, so nothing reaches buyers by surprise. ' +
|
|
158
|
+
'catalog_id is optional: left out, it uses the workspace default catalog, and if the workspace has NO ' +
|
|
159
|
+
'catalog at all it creates one (Main Catalog) on the way, so this works on a completely empty workspace. ' +
|
|
160
|
+
'Run km_get_catalog afterwards to see the new pathway with its id, then create packages under it. ' +
|
|
161
|
+
'This is a confirmed write; expect a 409 naming the pathway and catalog first.',
|
|
162
|
+
{
|
|
163
|
+
name: z.string().min(1).max(200).describe('The category name buyers read, e.g. "Dazzling Decor Club".'),
|
|
164
|
+
catalog_id: z.string().optional().describe('From km_list_catalogs. Leave it out for the default catalog, which is almost always right - and which will be created if the workspace has none.'),
|
|
165
|
+
subtitle: z.string().max(300).nullable().optional().describe('The small line under the name on the booking page, e.g. "Billed monthly, 3-month minimum".'),
|
|
166
|
+
description: z.string().max(2000).nullable().optional(),
|
|
167
|
+
status: z.enum(['draft', 'published']).optional().describe('Leave it out for draft, the safe default. published puts the category on the public page - but buyers still see nothing under it until its packages are published too.'),
|
|
168
|
+
image_url: z.string().max(1000).nullable().optional().describe('An image for the category card. A public URL; this tool does not upload anything.'),
|
|
169
|
+
display_seq: z.number().int().min(0).optional().describe('Position among the pathways, lowest first. Leave it out to land at the bottom.'),
|
|
170
|
+
confirm_token: z.string().optional().describe(CONFIRM_DESC),
|
|
171
|
+
},
|
|
172
|
+
async (body) => {
|
|
173
|
+
const r = await call('POST', '/pathways', body);
|
|
174
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
175
|
+
return out(r);
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
server.tool(
|
|
180
|
+
'km_update_pathway',
|
|
181
|
+
'Edit one booking pathway (the category packages sit under): rename it, change its subtitle or description, ' +
|
|
182
|
+
'publish or unpublish it, or move its position among the pathways (display_seq, lowest first). ' +
|
|
183
|
+
'Run km_get_catalog first and work from real ids. Send only the fields you are changing. ' +
|
|
184
|
+
'🚨 status is the PUBLIC switch, and it is only HALF the switch: a buyer sees a package only when the ' +
|
|
185
|
+
'PACKAGE is published AND its pathway is published. Publishing a pathway whose packages are all drafts ' +
|
|
186
|
+
'shows buyers an empty category; unpublishing a pathway hides every package under it at once, however ' +
|
|
187
|
+
'published those packages are. The response says which of those you just did. ' +
|
|
188
|
+
'This is a confirmed write; expect a 409 naming the pathway first.',
|
|
189
|
+
{
|
|
190
|
+
pathway_id: z.string().describe('UUID of the pathway, from km_get_catalog.'),
|
|
191
|
+
name: z.string().min(1).max(200).optional().describe('The category name buyers read.'),
|
|
192
|
+
subtitle: z.string().max(300).nullable().optional().describe('The small line under the name. Send null to clear it.'),
|
|
193
|
+
description: z.string().max(2000).nullable().optional().describe('Send null to clear it.'),
|
|
194
|
+
status: z.enum(['draft', 'published']).optional().describe('published puts the category on the public page. draft takes it, and everything under it, off.'),
|
|
195
|
+
display_seq: z.number().int().min(0).optional().describe('Position among the pathways, lowest first. Set these across siblings to reorder.'),
|
|
196
|
+
confirm_token: z.string().optional().describe(CONFIRM_DESC),
|
|
197
|
+
},
|
|
198
|
+
async ({ pathway_id, ...changes }) => {
|
|
199
|
+
const r = await call('PATCH', `/pathways/${encodeURIComponent(String(pathway_id || '').trim())}`, changes);
|
|
200
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
201
|
+
return out(r);
|
|
202
|
+
},
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
server.tool(
|
|
206
|
+
'km_create_package',
|
|
207
|
+
'Create a new package on the public booking catalog, at the bottom of a pathway. It is a DRAFT unless you ' +
|
|
208
|
+
'say otherwise, so nothing reaches buyers by surprise. Run km_get_catalog first for the pathway id; if the ' +
|
|
209
|
+
'catalog is empty and there is no pathway to put this under, make one with km_create_pathway. ' +
|
|
210
|
+
'Price is CENTS: 36500 is $365; the legacy proposal price is kept in step automatically. ' +
|
|
211
|
+
'After creating, say what the package is MADE OF with km_set_package_composition - that is what lets ' +
|
|
212
|
+
'quotes offer it as editable service lines and shows the owner the discount they are giving. ' +
|
|
213
|
+
'This is a confirmed write; expect a 409 naming the package and pathway first.',
|
|
214
|
+
{
|
|
215
|
+
pathway_id: z.string().describe('UUID of the pathway it lives under, from km_get_catalog.'),
|
|
216
|
+
name: z.string().min(1).max(200).describe('The name buyers read.'),
|
|
217
|
+
base_price_cents: z.number().int().min(0).nullable().optional().describe('The price the booking form charges, in cents. Leave it out for no price yet.'),
|
|
218
|
+
description: z.string().max(2000).nullable().optional(),
|
|
219
|
+
booking_status: z.enum(['draft', 'published']).optional().describe('Leave it out for draft, the safe default. published shows it to buyers the moment the pathway is also published.'),
|
|
220
|
+
confirm_token: z.string().optional().describe(CONFIRM_DESC),
|
|
221
|
+
},
|
|
222
|
+
async (body) => {
|
|
223
|
+
const r = await call('POST', '/packages', body);
|
|
224
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
225
|
+
return out(r);
|
|
226
|
+
},
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
server.tool(
|
|
230
|
+
'km_delete_package',
|
|
231
|
+
'Delete a package from the booking catalog, permanently. Its composition links and add-on options are ' +
|
|
232
|
+
'deleted with it, and there is no undo - which is why unpublishing (km_update_package with ' +
|
|
233
|
+
"booking_status 'draft') is usually the better first move, and this tool is for packages that are truly " +
|
|
234
|
+
'wrong, duplicates, or test rows. If bookings or quotes reference the package the delete is refused, and ' +
|
|
235
|
+
'unpublishing is the answer. This is a confirmed write; expect a 409 naming the package first.',
|
|
236
|
+
{
|
|
237
|
+
package_id: z.string().describe('UUID of the package, from km_get_catalog.'),
|
|
238
|
+
confirm_token: z.string().optional().describe(CONFIRM_DESC),
|
|
239
|
+
},
|
|
240
|
+
async ({ package_id, ...body }) => {
|
|
241
|
+
const r = await call('DELETE', `/packages/${encodeURIComponent(String(package_id || '').trim())}`, body);
|
|
242
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
243
|
+
return out(r);
|
|
244
|
+
},
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
server.tool(
|
|
248
|
+
'km_get_package_composition',
|
|
249
|
+
'What a package is MADE OF: the services inside it with quantities, each priced LIVE off the rate card, ' +
|
|
250
|
+
'plus the parts total and the discount against the package price. A package is an assortment of services ' +
|
|
251
|
+
'the OWNER selected - "3 hours of Balloon Twisting + 1 x 30-minute Magic Show" - never everything on the ' +
|
|
252
|
+
'rate card. The response also carries available_services (the whole rate card with ids), so this one call ' +
|
|
253
|
+
'gives you everything km_set_package_composition needs. An empty composition is a fine state: the package ' +
|
|
254
|
+
'sells on its price alone. Read only.',
|
|
255
|
+
{
|
|
256
|
+
package_id: z.string().describe('UUID of the package, from km_get_catalog.'),
|
|
257
|
+
},
|
|
258
|
+
async ({ package_id }) => {
|
|
259
|
+
const r = await call('GET', `/packages/${encodeURIComponent(String(package_id || '').trim())}/composition`);
|
|
260
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
261
|
+
return out(r);
|
|
262
|
+
},
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
server.tool(
|
|
266
|
+
'km_set_package_composition',
|
|
267
|
+
'Set what a package is made of, in ONE call: the full list of services and how much of each. Whatever you ' +
|
|
268
|
+
'send REPLACES the current composition, so start from km_get_package_composition and send the complete ' +
|
|
269
|
+
'list, not just the change. quantity is in the service\'s own unit: a per-hour service with quantity 3 ' +
|
|
270
|
+
'means 3 hours; decimals like 1.5 are fine. An empty list clears the composition (the package keeps its ' +
|
|
271
|
+
'price and stays bookable). Composition never changes what a buyer pays - the package price stands; it is ' +
|
|
272
|
+
'what lets quotes explode a package into editable service lines and shows the owner the discount they are ' +
|
|
273
|
+
'giving off their own rates. This is a confirmed write; expect a 409 spelling the composition out first.',
|
|
274
|
+
{
|
|
275
|
+
package_id: z.string().describe('UUID of the package, from km_get_catalog.'),
|
|
276
|
+
services: z.array(z.object({
|
|
277
|
+
service_id: z.string().describe('UUID of a rate-card service, from available_services on km_get_package_composition.'),
|
|
278
|
+
quantity: z.number().gt(0).max(999).describe("How much of it, in the service's own unit. 3 on a per-hour service = 3 hours."),
|
|
279
|
+
})).max(50).describe('The COMPLETE composition. It replaces what is there. Empty array clears it.'),
|
|
280
|
+
confirm_token: z.string().optional().describe(CONFIRM_DESC),
|
|
281
|
+
},
|
|
282
|
+
async ({ package_id, ...body }) => {
|
|
283
|
+
const r = await call('PUT', `/packages/${encodeURIComponent(String(package_id || '').trim())}/composition`, body);
|
|
284
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
285
|
+
return out(r);
|
|
286
|
+
},
|
|
287
|
+
);
|
|
288
|
+
}
|