@series-a/cli 1.0.0 → 1.2.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/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  All notable changes to `@series-a/cli` (and the paired `@series-a/mcp` server).
4
4
 
5
+ ## 1.2.0 — booking links and full profile-settings editing
6
+
7
+ ### Added
8
+ - `practice update` / `dentist update` `--booking <url>` and `--website <url>` shortcuts for setting the booking and website links without `--set`
9
+ - `--set-enrichment path=value` for nested practice profile values (positioning summary/archetype/marketing descriptions, amenities, default currency), so every field on the practice profile settings screen is editable from the CLI
10
+ - `--fields` now also lists the available `--set-enrichment` paths
11
+ - MCP `setu_profile_update` accepts `enrichment_set`
12
+
13
+ ## 1.1.0 — editing, pricing and media
14
+
15
+ ### Added
16
+ - `practice update` / `dentist update` — edit whitelisted fields on unclaimed seeded profiles (`--set field=value`, repeatable; `--fields` lists what's allowed)
17
+ - `practice images` / `dentist images` — add, remove (by URL or index), reorder, replace or clear gallery photos, and set the logo / main photo
18
+ - `practice pricing` / `dentist pricing` — read, set, remove or clear treatment prices
19
+ - `dentist delete` / `dentist delete --unpublish`
20
+ - Much wider field allowlists, including services, all private fee columns, specialties, languages and matching fields
21
+ - MCP tools `setu_profile_update`, `setu_profile_images` and `setu_profile_pricing`
22
+
5
23
  ## 1.0.0 — first stable release
6
24
 
7
25
  First release marked stable for internal team use.
package/README.md CHANGED
@@ -88,11 +88,64 @@ setu practice import practices.csv --resume results.json # retry failures only
88
88
  Rows need either a `website`/`url`, or a `name` plus `town`/`city`; anything
89
89
  else is reported and skipped before any API call is made.
90
90
 
91
+ ## Editing seeded profiles
92
+
93
+ ```bash
94
+ setu practice update <practice-id> --fields # what you can set
95
+ setu practice update <practice-id> --set tagline="Gentle care" --set emergency_available=true
96
+ setu dentist update <dentist-id> --set bio="20 years in implants"
97
+
98
+ # Booking / website link shortcuts
99
+ setu practice update <practice-id> --booking https://clinic.co.uk/book
100
+ setu dentist update <dentist-id> --booking https://clinic.co.uk/book --website https://clinic.co.uk
101
+ ```
102
+
103
+ Lists take comma-separated values, e.g. `--set services="Implants,Whitening"`.
104
+
105
+ Everything on the practice profile settings screen is settable. The few values that
106
+ live in the nested profile record — positioning, amenities, default currency — use
107
+ `--set-enrichment`:
108
+
109
+ ```bash
110
+ setu practice update <practice-id> --set-enrichment positioning.summary="Calm, modern care in Leeds"
111
+ setu practice update <practice-id> --set-enrichment amenities="Wi-Fi,Free parking,Step-free access"
112
+ setu practice update <practice-id> --set-enrichment positioning.archetype=boutique
113
+ setu practice update <practice-id> --set parking_info="Two-hour free bay on site" \
114
+ --set accessibility="Step-free,Hearing loop" --set languages="English,Polish"
115
+ ```
116
+
117
+ ## Photos and logos
118
+
119
+ ```bash
120
+ setu practice images <practice-id> --list
121
+ setu practice images <practice-id> --add https://.../photo.jpg
122
+ setu practice images <practice-id> --remove 2 # by index, or pass the URL
123
+ setu practice images <practice-id> --order "https://.../hero.jpg" # move these to the front
124
+ setu practice images <practice-id> --replace "https://a.jpg,https://b.jpg"
125
+ setu practice images <practice-id> --logo https://.../logo.png
126
+ setu dentist images <dentist-id> --logo https://.../headshot.jpg # main photo
127
+ ```
128
+
129
+ ## Prices and services
130
+
131
+ ```bash
132
+ setu practice pricing <practice-id> --list
133
+ setu practice pricing <practice-id> --set "Dental implant=1200" --set "Whitening=300-450"
134
+ setu practice pricing <practice-id> --remove "Whitening"
135
+ setu dentist pricing <dentist-id> --set "Hygiene visit=75"
136
+ setu practice update <practice-id> --set services="Implants,Whitening,Invisalign"
137
+ ```
138
+
139
+ Practice prices update the practice's treatment list too. Dentist prices are
140
+ saved as individual treatment rows shown on the clinician's profile.
141
+
91
142
  ## Removing seeded profiles
92
143
 
93
144
  ```bash
94
145
  setu practice delete <practice-id> --unpublish # hide from patients
95
146
  setu practice delete <practice-id> # delete (unclaimed only)
147
+ setu dentist delete <dentist-id> --unpublish
148
+ setu dentist delete <dentist-id>
96
149
  ```
97
150
 
98
151
  Claimed or user-owned profiles are always refused.
package/bin/setu.mjs CHANGED
@@ -32,8 +32,12 @@ function parseArgs(argv) {
32
32
  if (a.startsWith("--")) {
33
33
  const key = a.slice(2);
34
34
  const next = argv[i + 1];
35
- if (!next || next.startsWith("--")) flags[key] = true;
36
- else { flags[key] = next; i++; }
35
+ let value = true;
36
+ if (next && !next.startsWith("--")) { value = next; i++; }
37
+ // Repeatable flags (--set, --add, --remove) collect into arrays.
38
+ if (key in flags) {
39
+ flags[key] = Array.isArray(flags[key]) ? [...flags[key], value] : [flags[key], value];
40
+ } else flags[key] = value;
37
41
  } else positional.push(a);
38
42
  }
39
43
  return { positional, flags };
@@ -65,8 +69,23 @@ ${c.bold("Commands")}
65
69
  [--rotate]
66
70
  practice delete <practice-id> Remove an unclaimed seeded profile
67
71
  [--unpublish] [--yes]
72
+ practice update <practice-id> Edit fields on a seeded profile
73
+ --set field=value repeatable, e.g. --set tagline="Gentle care"
74
+ --set-enrichment path=value e.g. --set-enrichment amenities="Wi-Fi|Parking"
75
+ [--booking <url>] [--website <url>] shortcuts for the booking / website links
76
+ [--fields] list the fields you can set
77
+ practice images <practice-id> Manage photos on a seeded profile
78
+ [--add <url>] [--remove <url|index>] [--order "url1,url2"]
79
+ [--logo <url>] [--replace "url1,url2"] [--clear] [--list]
80
+ practice pricing <practice-id> Manage treatment prices
81
+ [--set "Implant=1200"] [--remove "Implant"] [--clear] [--list]
68
82
  dentist create --name "Dr Jane Roe" Seed one clinician
69
83
  [--practice <id>] [--profile-url <url>] [--town <town>]
84
+ dentist update <dentist-id> Edit fields on a seeded clinician
85
+ dentist images <dentist-id> Manage a clinician's photos
86
+ dentist pricing <dentist-id> Manage a clinician's treatment prices
87
+ dentist delete <dentist-id> Remove an unclaimed seeded clinician
88
+ [--unpublish] [--yes]
70
89
  status [<practice-id>] List or inspect seeded profiles
71
90
 
72
91
  ${c.bold("Global")} --json --yes --verbose --operator <name> --version
@@ -88,7 +107,7 @@ function cmdLogout() {
88
107
  ok(`Signed out — removed ${configPath()}`);
89
108
  }
90
109
 
91
- async function cmdPracticeDelete(id) {
110
+ async function cmdPracticeDelete(id, type = "practice") {
92
111
  if (!id) { fail("Usage: setu practice delete <practice-id> [--unpublish]"); process.exit(1); }
93
112
  const unpublishOnly = !!flags.unpublish;
94
113
  if (!JSON_OUT && !flags.yes) {
@@ -101,13 +120,152 @@ async function cmdPracticeDelete(id) {
101
120
  }
102
121
  const res = await call("cli-profile-delete", {
103
122
  id,
104
- profile_type: flags.dentist ? "dentist" : "practice",
123
+ profile_type: type === "dentist" || flags.dentist ? "dentist" : "practice",
105
124
  unpublish_only: unpublishOnly,
106
125
  });
107
126
  if (JSON_OUT) return out(res);
108
127
  ok(`${res.name} — ${res.action}`);
109
128
  }
110
129
 
130
+ const asList = (v) => (v === undefined || v === true ? [] : Array.isArray(v) ? v : [v]);
131
+ const splitList = (v) =>
132
+ asList(v).flatMap((item) => String(item).split(",").map((s) => s.trim()).filter(Boolean));
133
+
134
+ async function cmdProfileUpdate(type, id) {
135
+ if (flags.fields) {
136
+ // The server answers with the whitelist when nothing valid is sent.
137
+ try {
138
+ const r = await call("cli-profile-update", { id: id || "00000000-0000-0000-0000-000000000000", profile_type: type, read: true });
139
+ if (JSON_OUT) return out({ fields: r.allowed_fields, enrichment_paths: r.allowed_enrichment_paths ?? [] });
140
+ heading(`Fields you can set on a ${type}`);
141
+ log(" " + r.allowed_fields.join(", "));
142
+ if (r.allowed_enrichment_paths?.length) {
143
+ heading("Extra fields (use --set-enrichment path=value)");
144
+ log(" " + r.allowed_enrichment_paths.join(", "));
145
+ }
146
+ return;
147
+ } catch (e) {
148
+ const fields = e.data?.allowed_fields;
149
+ if (fields) {
150
+ if (JSON_OUT) return out({ fields });
151
+ heading(`Fields you can set on a ${type}`);
152
+ log(" " + fields.join(", "));
153
+ return;
154
+ }
155
+ throw e;
156
+ }
157
+ return;
158
+ }
159
+ if (!id) { fail(`Usage: setu ${type} update <id> [--set field=value] [--booking <url>] [--website <url>]`); process.exit(1); }
160
+
161
+ const set = {};
162
+ const pairs = asList(flags.set);
163
+ for (const pair of pairs) {
164
+ const raw = String(pair);
165
+ const eq = raw.indexOf("=");
166
+ if (eq < 1) { fail(`Bad --set value: "${raw}". Use field=value.`); process.exit(1); }
167
+ set[raw.slice(0, eq).trim()] = raw.slice(eq + 1);
168
+ }
169
+ const enrichmentSet = {};
170
+ for (const pair of asList(flags["set-enrichment"])) {
171
+ const raw = String(pair);
172
+ const eq = raw.indexOf("=");
173
+ if (eq < 1) { fail(`Bad --set-enrichment value: "${raw}". Use path=value.`); process.exit(1); }
174
+ enrichmentSet[raw.slice(0, eq).trim()] = raw.slice(eq + 1);
175
+ }
176
+ if (flags.booking !== undefined) set.booking_url = flags.booking === true ? "" : String(flags.booking);
177
+ if (flags.website !== undefined) set[type === "dentist" ? "website_url" : "website"] = flags.website === true ? "" : String(flags.website);
178
+ if (!Object.keys(set).length && !Object.keys(enrichmentSet).length) { fail("Pass at least one --set field=value, --set-enrichment path=value, --booking <url> or --website <url> (or --fields to see what you can set)."); process.exit(1); }
179
+
180
+ const res = await call("cli-profile-update", {
181
+ id,
182
+ profile_type: type,
183
+ set,
184
+ ...(Object.keys(enrichmentSet).length ? { enrichment_set: enrichmentSet } : {}),
185
+ });
186
+ if (JSON_OUT) return out(res);
187
+ ok(`${res.name} — updated ${res.updated.join(", ")}`);
188
+ if (res.rejected?.length) warn(`Ignored unknown field(s): ${res.rejected.join(", ")}`);
189
+ }
190
+
191
+ async function cmdProfileImages(type, id) {
192
+ if (!id) { fail(`Usage: setu ${type} images <id> [--add <url>] [--remove <url|index>] [--order "a,b"] [--logo <url>]`); process.exit(1); }
193
+
194
+ const images = {};
195
+ if (splitList(flags.add).length) images.add = splitList(flags.add);
196
+ if (splitList(flags.remove).length) images.remove = splitList(flags.remove);
197
+ if (splitList(flags.order).length) images.order = splitList(flags.order);
198
+ if (splitList(flags.replace).length) images.replace = splitList(flags.replace);
199
+ if (flags.clear === true) images.clear = true;
200
+ if (flags.logo !== undefined) images.logo = flags.logo === true ? null : String(flags.logo);
201
+
202
+ if (!Object.keys(images).length || flags.list) {
203
+ // Listing is a no-op update: ask the server for the current state.
204
+ const res = await call("cli-profile-update", { id, profile_type: type, read: true });
205
+ if (JSON_OUT) return out(res);
206
+ heading(`${res.name} — photos (${res.gallery.length})`);
207
+ res.gallery.forEach((url, i) => log(` ${String(i).padEnd(3)} ${url}`));
208
+ log(` ${c.dim(type === "dentist" ? "main photo" : "logo")}: ${res.logo ?? c.dim("none")}`);
209
+ return;
210
+ }
211
+
212
+ if (images.clear && !JSON_OUT && !flags.yes) {
213
+ const proceed = await confirm("Remove every photo from this profile?");
214
+ if (!proceed) { warn("Cancelled."); return; }
215
+ }
216
+
217
+ const res = await call("cli-profile-update", { id, profile_type: type, images });
218
+ if (JSON_OUT) return out(res);
219
+ ok(`${res.name} — ${res.gallery.length} photo(s)${res.logo ? ", logo set" : ""}`);
220
+ res.gallery.forEach((url, i) => log(` ${String(i).padEnd(3)} ${c.dim(url)}`));
221
+ }
222
+
223
+ async function cmdProfilePricing(type, id) {
224
+ if (!id) { fail(`Usage: setu ${type} pricing <id> [--set "Implant=1200"] [--remove "Implant"] [--clear] [--list]`); process.exit(1); }
225
+
226
+ const setPairs = asList(flags.set);
227
+ const removals = splitList(flags.remove);
228
+ const wantsWrite = setPairs.length || removals.length || flags.clear === true;
229
+
230
+ if (!wantsWrite || flags.list) {
231
+ const res = await call("cli-profile-update", { id, profile_type: type, read: true });
232
+ if (JSON_OUT) return out({ id, pricing: res.pricing ?? [] });
233
+ const rows = res.pricing ?? [];
234
+ heading(`${res.name} — prices (${rows.length})`);
235
+ if (!rows.length) log(c.dim(" none yet — add with --set \"Implant=1200\""));
236
+ rows.forEach((r) => {
237
+ const range = r.amount_to ? `${r.amount}–${r.amount_to}` : r.amount;
238
+ log(` ${String(r.label).padEnd(34)} £${range ?? "?"}`);
239
+ });
240
+ return;
241
+ }
242
+
243
+ const pricing = {};
244
+ if (setPairs.length) {
245
+ pricing.set = {};
246
+ for (const pair of setPairs) {
247
+ const raw = String(pair);
248
+ const eq = raw.indexOf("=");
249
+ if (eq < 1) { fail(`Bad --set value: "${raw}". Use "Treatment=amount" (or "Treatment=800-1200").`); process.exit(1); }
250
+ pricing.set[raw.slice(0, eq).trim()] = raw.slice(eq + 1).trim();
251
+ }
252
+ }
253
+ if (removals.length) pricing.remove = removals;
254
+ if (flags.clear === true) {
255
+ if (!JSON_OUT && !flags.yes) {
256
+ const proceed = await confirm("Remove every price from this profile?");
257
+ if (!proceed) { warn("Cancelled."); return; }
258
+ }
259
+ pricing.clear = true;
260
+ }
261
+
262
+ const res = await call("cli-profile-update", { id, profile_type: type, pricing });
263
+ if (JSON_OUT) return out(res);
264
+ const rows = res.pricing ?? [];
265
+ ok(`${res.name} — ${rows.length} price(s) on file`);
266
+ rows.forEach((r) => log(` ${String(r.label).padEnd(34)} ${c.dim(`£${r.amount_to ? `${r.amount}–${r.amount_to}` : r.amount}`)}`));
267
+ }
268
+
111
269
  async function cmdWhoami() {
112
270
  const res = await call("cli-auth", {});
113
271
  const cfg = loadConfig();
@@ -344,7 +502,7 @@ async function cmdClaimLink(id) {
344
502
  if (!id) { fail("Usage: setu practice claim-link <practice-id>"); process.exit(1); }
345
503
  const res = await call("cli-claim-link", {
346
504
  id,
347
- profile_type: flags.dentist ? "dentist" : "practice",
505
+ profile_type: type === "dentist" || flags.dentist ? "dentist" : "practice",
348
506
  rotate: !!flags.rotate,
349
507
  });
350
508
  if (JSON_OUT) return out(res);
@@ -414,6 +572,9 @@ async function main() {
414
572
  case "import": return cmdPracticeImport(arg);
415
573
  case "claim-link": return cmdClaimLink(arg);
416
574
  case "delete": return cmdPracticeDelete(arg);
575
+ case "update": return cmdProfileUpdate("practice", arg);
576
+ case "images": return cmdProfileImages("practice", arg);
577
+ case "pricing": return cmdProfilePricing("practice", arg);
417
578
  default:
418
579
  fail(`Unknown command: practice ${sub ?? ""}`.trim());
419
580
  usage();
@@ -422,6 +583,10 @@ async function main() {
422
583
  }
423
584
  case "dentist":
424
585
  if (sub === "create") return cmdDentistCreate();
586
+ if (sub === "update") return cmdProfileUpdate("dentist", arg);
587
+ if (sub === "images") return cmdProfileImages("dentist", arg);
588
+ if (sub === "pricing") return cmdProfilePricing("dentist", arg);
589
+ if (sub === "delete") return cmdPracticeDelete(arg, "dentist");
425
590
  fail(`Unknown command: dentist ${sub ?? ""}`.trim());
426
591
  usage();
427
592
  process.exitCode = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@series-a/cli",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "private": false,
5
5
  "description": "Setu CLI — build, review and publish claimable practice and dentist profiles.",
6
6
  "type": "module",