@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 +18 -0
- package/README.md +53 -0
- package/bin/setu.mjs +170 -5
- package/package.json +1 -1
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
|
-
|
|
36
|
-
|
|
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;
|