@series-a/cli 1.0.0 → 1.1.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,16 @@
2
2
 
3
3
  All notable changes to `@series-a/cli` (and the paired `@series-a/mcp` server).
4
4
 
5
+ ## 1.1.0 — editing, pricing and media
6
+
7
+ ### Added
8
+ - `practice update` / `dentist update` — edit whitelisted fields on unclaimed seeded profiles (`--set field=value`, repeatable; `--fields` lists what's allowed)
9
+ - `practice images` / `dentist images` — add, remove (by URL or index), reorder, replace or clear gallery photos, and set the logo / main photo
10
+ - `practice pricing` / `dentist pricing` — read, set, remove or clear treatment prices
11
+ - `dentist delete` / `dentist delete --unpublish`
12
+ - Much wider field allowlists, including services, all private fee columns, specialties, languages and matching fields
13
+ - MCP tools `setu_profile_update`, `setu_profile_images` and `setu_profile_pricing`
14
+
5
15
  ## 1.0.0 — first stable release
6
16
 
7
17
  First release marked stable for internal team use.
package/README.md CHANGED
@@ -88,11 +88,48 @@ 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
+
99
+ Lists take comma-separated values, e.g. `--set services="Implants,Whitening"`.
100
+
101
+ ## Photos and logos
102
+
103
+ ```bash
104
+ setu practice images <practice-id> --list
105
+ setu practice images <practice-id> --add https://.../photo.jpg
106
+ setu practice images <practice-id> --remove 2 # by index, or pass the URL
107
+ setu practice images <practice-id> --order "https://.../hero.jpg" # move these to the front
108
+ setu practice images <practice-id> --replace "https://a.jpg,https://b.jpg"
109
+ setu practice images <practice-id> --logo https://.../logo.png
110
+ setu dentist images <dentist-id> --logo https://.../headshot.jpg # main photo
111
+ ```
112
+
113
+ ## Prices and services
114
+
115
+ ```bash
116
+ setu practice pricing <practice-id> --list
117
+ setu practice pricing <practice-id> --set "Dental implant=1200" --set "Whitening=300-450"
118
+ setu practice pricing <practice-id> --remove "Whitening"
119
+ setu dentist pricing <dentist-id> --set "Hygiene visit=75"
120
+ setu practice update <practice-id> --set services="Implants,Whitening,Invisalign"
121
+ ```
122
+
123
+ Practice prices update the practice's treatment list too. Dentist prices are
124
+ saved as individual treatment rows shown on the clinician's profile.
125
+
91
126
  ## Removing seeded profiles
92
127
 
93
128
  ```bash
94
129
  setu practice delete <practice-id> --unpublish # hide from patients
95
130
  setu practice delete <practice-id> # delete (unclaimed only)
131
+ setu dentist delete <dentist-id> --unpublish
132
+ setu dentist delete <dentist-id>
96
133
  ```
97
134
 
98
135
  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,21 @@ ${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
+ [--fields] list the fields you can set
75
+ practice images <practice-id> Manage photos on a seeded profile
76
+ [--add <url>] [--remove <url|index>] [--order "url1,url2"]
77
+ [--logo <url>] [--replace "url1,url2"] [--clear] [--list]
78
+ practice pricing <practice-id> Manage treatment prices
79
+ [--set "Implant=1200"] [--remove "Implant"] [--clear] [--list]
68
80
  dentist create --name "Dr Jane Roe" Seed one clinician
69
81
  [--practice <id>] [--profile-url <url>] [--town <town>]
82
+ dentist update <dentist-id> Edit fields on a seeded clinician
83
+ dentist images <dentist-id> Manage a clinician's photos
84
+ dentist pricing <dentist-id> Manage a clinician's treatment prices
85
+ dentist delete <dentist-id> Remove an unclaimed seeded clinician
86
+ [--unpublish] [--yes]
70
87
  status [<practice-id>] List or inspect seeded profiles
71
88
 
72
89
  ${c.bold("Global")} --json --yes --verbose --operator <name> --version
@@ -88,7 +105,7 @@ function cmdLogout() {
88
105
  ok(`Signed out — removed ${configPath()}`);
89
106
  }
90
107
 
91
- async function cmdPracticeDelete(id) {
108
+ async function cmdPracticeDelete(id, type = "practice") {
92
109
  if (!id) { fail("Usage: setu practice delete <practice-id> [--unpublish]"); process.exit(1); }
93
110
  const unpublishOnly = !!flags.unpublish;
94
111
  if (!JSON_OUT && !flags.yes) {
@@ -101,13 +118,134 @@ async function cmdPracticeDelete(id) {
101
118
  }
102
119
  const res = await call("cli-profile-delete", {
103
120
  id,
104
- profile_type: flags.dentist ? "dentist" : "practice",
121
+ profile_type: type === "dentist" || flags.dentist ? "dentist" : "practice",
105
122
  unpublish_only: unpublishOnly,
106
123
  });
107
124
  if (JSON_OUT) return out(res);
108
125
  ok(`${res.name} — ${res.action}`);
109
126
  }
110
127
 
128
+ const asList = (v) => (v === undefined || v === true ? [] : Array.isArray(v) ? v : [v]);
129
+ const splitList = (v) =>
130
+ asList(v).flatMap((item) => String(item).split(",").map((s) => s.trim()).filter(Boolean));
131
+
132
+ async function cmdProfileUpdate(type, id) {
133
+ if (flags.fields) {
134
+ // The server answers with the whitelist when nothing valid is sent.
135
+ try {
136
+ const r = await call("cli-profile-update", { id: id || "00000000-0000-0000-0000-000000000000", profile_type: type, read: true });
137
+ if (JSON_OUT) return out({ fields: r.allowed_fields });
138
+ heading(`Fields you can set on a ${type}`);
139
+ log(" " + r.allowed_fields.join(", "));
140
+ return;
141
+ } catch (e) {
142
+ const fields = e.data?.allowed_fields;
143
+ if (fields) {
144
+ if (JSON_OUT) return out({ fields });
145
+ heading(`Fields you can set on a ${type}`);
146
+ log(" " + fields.join(", "));
147
+ return;
148
+ }
149
+ throw e;
150
+ }
151
+ return;
152
+ }
153
+ if (!id) { fail(`Usage: setu ${type} update <id> --set field=value`); process.exit(1); }
154
+
155
+ const pairs = asList(flags.set);
156
+ if (!pairs.length) { fail("Pass at least one --set field=value (or --fields to see what you can set)."); process.exit(1); }
157
+ const set = {};
158
+ for (const pair of pairs) {
159
+ const raw = String(pair);
160
+ const eq = raw.indexOf("=");
161
+ if (eq < 1) { fail(`Bad --set value: "${raw}". Use field=value.`); process.exit(1); }
162
+ set[raw.slice(0, eq).trim()] = raw.slice(eq + 1);
163
+ }
164
+
165
+ const res = await call("cli-profile-update", { id, profile_type: type, set });
166
+ if (JSON_OUT) return out(res);
167
+ ok(`${res.name} — updated ${res.updated.join(", ")}`);
168
+ if (res.rejected?.length) warn(`Ignored unknown field(s): ${res.rejected.join(", ")}`);
169
+ }
170
+
171
+ async function cmdProfileImages(type, id) {
172
+ if (!id) { fail(`Usage: setu ${type} images <id> [--add <url>] [--remove <url|index>] [--order "a,b"] [--logo <url>]`); process.exit(1); }
173
+
174
+ const images = {};
175
+ if (splitList(flags.add).length) images.add = splitList(flags.add);
176
+ if (splitList(flags.remove).length) images.remove = splitList(flags.remove);
177
+ if (splitList(flags.order).length) images.order = splitList(flags.order);
178
+ if (splitList(flags.replace).length) images.replace = splitList(flags.replace);
179
+ if (flags.clear === true) images.clear = true;
180
+ if (flags.logo !== undefined) images.logo = flags.logo === true ? null : String(flags.logo);
181
+
182
+ if (!Object.keys(images).length || flags.list) {
183
+ // Listing is a no-op update: ask the server for the current state.
184
+ const res = await call("cli-profile-update", { id, profile_type: type, read: true });
185
+ if (JSON_OUT) return out(res);
186
+ heading(`${res.name} — photos (${res.gallery.length})`);
187
+ res.gallery.forEach((url, i) => log(` ${String(i).padEnd(3)} ${url}`));
188
+ log(` ${c.dim(type === "dentist" ? "main photo" : "logo")}: ${res.logo ?? c.dim("none")}`);
189
+ return;
190
+ }
191
+
192
+ if (images.clear && !JSON_OUT && !flags.yes) {
193
+ const proceed = await confirm("Remove every photo from this profile?");
194
+ if (!proceed) { warn("Cancelled."); return; }
195
+ }
196
+
197
+ const res = await call("cli-profile-update", { id, profile_type: type, images });
198
+ if (JSON_OUT) return out(res);
199
+ ok(`${res.name} — ${res.gallery.length} photo(s)${res.logo ? ", logo set" : ""}`);
200
+ res.gallery.forEach((url, i) => log(` ${String(i).padEnd(3)} ${c.dim(url)}`));
201
+ }
202
+
203
+ async function cmdProfilePricing(type, id) {
204
+ if (!id) { fail(`Usage: setu ${type} pricing <id> [--set "Implant=1200"] [--remove "Implant"] [--clear] [--list]`); process.exit(1); }
205
+
206
+ const setPairs = asList(flags.set);
207
+ const removals = splitList(flags.remove);
208
+ const wantsWrite = setPairs.length || removals.length || flags.clear === true;
209
+
210
+ if (!wantsWrite || flags.list) {
211
+ const res = await call("cli-profile-update", { id, profile_type: type, read: true });
212
+ if (JSON_OUT) return out({ id, pricing: res.pricing ?? [] });
213
+ const rows = res.pricing ?? [];
214
+ heading(`${res.name} — prices (${rows.length})`);
215
+ if (!rows.length) log(c.dim(" none yet — add with --set \"Implant=1200\""));
216
+ rows.forEach((r) => {
217
+ const range = r.amount_to ? `${r.amount}–${r.amount_to}` : r.amount;
218
+ log(` ${String(r.label).padEnd(34)} £${range ?? "?"}`);
219
+ });
220
+ return;
221
+ }
222
+
223
+ const pricing = {};
224
+ if (setPairs.length) {
225
+ pricing.set = {};
226
+ for (const pair of setPairs) {
227
+ const raw = String(pair);
228
+ const eq = raw.indexOf("=");
229
+ if (eq < 1) { fail(`Bad --set value: "${raw}". Use "Treatment=amount" (or "Treatment=800-1200").`); process.exit(1); }
230
+ pricing.set[raw.slice(0, eq).trim()] = raw.slice(eq + 1).trim();
231
+ }
232
+ }
233
+ if (removals.length) pricing.remove = removals;
234
+ if (flags.clear === true) {
235
+ if (!JSON_OUT && !flags.yes) {
236
+ const proceed = await confirm("Remove every price from this profile?");
237
+ if (!proceed) { warn("Cancelled."); return; }
238
+ }
239
+ pricing.clear = true;
240
+ }
241
+
242
+ const res = await call("cli-profile-update", { id, profile_type: type, pricing });
243
+ if (JSON_OUT) return out(res);
244
+ const rows = res.pricing ?? [];
245
+ ok(`${res.name} — ${rows.length} price(s) on file`);
246
+ rows.forEach((r) => log(` ${String(r.label).padEnd(34)} ${c.dim(`£${r.amount_to ? `${r.amount}–${r.amount_to}` : r.amount}`)}`));
247
+ }
248
+
111
249
  async function cmdWhoami() {
112
250
  const res = await call("cli-auth", {});
113
251
  const cfg = loadConfig();
@@ -344,7 +482,7 @@ async function cmdClaimLink(id) {
344
482
  if (!id) { fail("Usage: setu practice claim-link <practice-id>"); process.exit(1); }
345
483
  const res = await call("cli-claim-link", {
346
484
  id,
347
- profile_type: flags.dentist ? "dentist" : "practice",
485
+ profile_type: type === "dentist" || flags.dentist ? "dentist" : "practice",
348
486
  rotate: !!flags.rotate,
349
487
  });
350
488
  if (JSON_OUT) return out(res);
@@ -414,6 +552,9 @@ async function main() {
414
552
  case "import": return cmdPracticeImport(arg);
415
553
  case "claim-link": return cmdClaimLink(arg);
416
554
  case "delete": return cmdPracticeDelete(arg);
555
+ case "update": return cmdProfileUpdate("practice", arg);
556
+ case "images": return cmdProfileImages("practice", arg);
557
+ case "pricing": return cmdProfilePricing("practice", arg);
417
558
  default:
418
559
  fail(`Unknown command: practice ${sub ?? ""}`.trim());
419
560
  usage();
@@ -422,6 +563,10 @@ async function main() {
422
563
  }
423
564
  case "dentist":
424
565
  if (sub === "create") return cmdDentistCreate();
566
+ if (sub === "update") return cmdProfileUpdate("dentist", arg);
567
+ if (sub === "images") return cmdProfileImages("dentist", arg);
568
+ if (sub === "pricing") return cmdProfilePricing("dentist", arg);
569
+ if (sub === "delete") return cmdPracticeDelete(arg, "dentist");
425
570
  fail(`Unknown command: dentist ${sub ?? ""}`.trim());
426
571
  usage();
427
572
  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.1.0",
4
4
  "private": false,
5
5
  "description": "Setu CLI — build, review and publish claimable practice and dentist profiles.",
6
6
  "type": "module",