@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 +10 -0
- package/README.md +37 -0
- package/bin/setu.mjs +150 -5
- package/package.json +1 -1
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
|
-
|
|
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,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;
|