@series-a/cli 1.2.0 → 1.3.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 +16 -0
- package/README.md +33 -1
- package/bin/setu.mjs +207 -17
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@series-a/cli` (and the paired `@series-a/mcp` server).
|
|
4
4
|
|
|
5
|
+
## Unreleased — claims review dashboard
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- `setu claims` — review dashboard showing pending claim requests, counts by status and unclaimed profiles scored for readiness (with what's still missing)
|
|
9
|
+
- `setu claims show|approve|decline <claim-id>` (`--reason`, `--yes`), plus `setu claims --status approved|rejected|all`
|
|
10
|
+
- MCP tools `setu_claims_dashboard`, `setu_claims_list`, `setu_claim_detail` and `setu_claim_decide`
|
|
11
|
+
|
|
12
|
+
## 1.3.0 — lists, full profiles and image uploads
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- `practice list` and `dentist list` — browse seeded profiles, with `--limit`, `--search` and (for clinicians) `--practice`
|
|
16
|
+
- `setu status <id> --full` and `setu status --dentist` — the complete enriched profile including photos, prices, services and attached clinicians
|
|
17
|
+
- `setu <type> images --upload <file|url>` and `--upload-logo <file|url>` — upload photos straight into Setu storage with your CLI key, no third-party hosting needed
|
|
18
|
+
- `dentist create` now honours `--photo`, `--gallery`, `--phone`, `--website`, `--booking` and `--bio`
|
|
19
|
+
- MCP tool `setu_profile_upload_image`; `setu_profile_status` gained `full`, `list`, `limit`, `search` and `practice_id`; `setu_dentist_create` gained photo/gallery/contact fields
|
|
20
|
+
|
|
5
21
|
## 1.2.0 — booking links and full profile-settings editing
|
|
6
22
|
|
|
7
23
|
### Added
|
package/README.md
CHANGED
|
@@ -48,6 +48,8 @@ are listed at the end so you can retry them.
|
|
|
48
48
|
setu practice claim-link <practice-id>
|
|
49
49
|
setu status # every seeded profile and its claim state
|
|
50
50
|
setu status <practice-id> # one profile plus its claim attempts
|
|
51
|
+
setu status <profile-id> --full # every field, photos, prices, services, clinicians
|
|
52
|
+
setu status --dentist # list clinicians instead of practices
|
|
51
53
|
```
|
|
52
54
|
|
|
53
55
|
Send the claim link in outreach. The practice signs in, and is verified either
|
|
@@ -56,10 +58,31 @@ contact address on their Google listing. On success the profile transfers to
|
|
|
56
58
|
them, pre-filled, and they continue through the normal onboarding and
|
|
57
59
|
subscription flow.
|
|
58
60
|
|
|
61
|
+
## Reviewing claims
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
setu claims # pending claims + unclaimed profiles by readiness
|
|
65
|
+
setu claims --ready # only profiles ready to promote
|
|
66
|
+
setu claims --status all # every claim, any status
|
|
67
|
+
setu claims show <claim-id> # claimant, method, attempts, target profile
|
|
68
|
+
setu claims approve <claim-id> # transfer the profile to the claimant
|
|
69
|
+
setu claims decline <claim-id> --reason "Not the practice owner"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Approving a practice claim makes the claimant the owner, adds an owner
|
|
73
|
+
membership and marks the practice's clinicians as claimed. A claim can only be
|
|
74
|
+
approved once the claimant has signed in, so there's an account to transfer to.
|
|
75
|
+
|
|
59
76
|
## Clinicians
|
|
60
77
|
|
|
61
78
|
```bash
|
|
62
|
-
setu dentist create --name "Dr Jane Roe" --practice <id> --profile-url https://…/team/jane
|
|
79
|
+
setu dentist create --name "Dr Jane Roe" --practice <id> --profile-url https://…/team/jane \
|
|
80
|
+
--photo https://…/jane.jpg --gallery "https://…/a.jpg,https://…/b.jpg" \
|
|
81
|
+
--phone 02392000000 --website https://… --booking https://…/book
|
|
82
|
+
|
|
83
|
+
setu practice list --limit 100 --search "smile"
|
|
84
|
+
setu dentist list --practice <practice-id>
|
|
85
|
+
setu dentist status <dentist-id> --full
|
|
63
86
|
```
|
|
64
87
|
|
|
65
88
|
## Flags
|
|
@@ -126,6 +149,15 @@ setu practice images <practice-id> --logo https://.../logo.png
|
|
|
126
149
|
setu dentist images <dentist-id> --logo https://.../headshot.jpg # main photo
|
|
127
150
|
```
|
|
128
151
|
|
|
152
|
+
Upload from your machine (or copy a remote image into Setu storage) instead of
|
|
153
|
+
linking to someone else's server:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
setu practice images <practice-id> --upload ./reception.jpg
|
|
157
|
+
setu practice images <practice-id> --upload-logo ./logo.png
|
|
158
|
+
setu dentist images <dentist-id> --upload-logo https://.../headshot.jpg
|
|
159
|
+
```
|
|
160
|
+
|
|
129
161
|
## Prices and services
|
|
130
162
|
|
|
131
163
|
```bash
|
package/bin/setu.mjs
CHANGED
|
@@ -77,16 +77,29 @@ ${c.bold("Commands")}
|
|
|
77
77
|
practice images <practice-id> Manage photos on a seeded profile
|
|
78
78
|
[--add <url>] [--remove <url|index>] [--order "url1,url2"]
|
|
79
79
|
[--logo <url>] [--replace "url1,url2"] [--clear] [--list]
|
|
80
|
+
[--upload <file|url>] [--upload-logo <file|url>]
|
|
80
81
|
practice pricing <practice-id> Manage treatment prices
|
|
81
82
|
[--set "Implant=1200"] [--remove "Implant"] [--clear] [--list]
|
|
83
|
+
practice list List seeded practices
|
|
84
|
+
[--limit 50] [--search <name>]
|
|
82
85
|
dentist create --name "Dr Jane Roe" Seed one clinician
|
|
83
86
|
[--practice <id>] [--profile-url <url>] [--town <town>]
|
|
87
|
+
[--photo <url>] [--gallery "url1,url2"] [--phone <n>]
|
|
88
|
+
[--website <url>] [--booking <url>] [--bio "..."]
|
|
89
|
+
dentist list List seeded clinicians
|
|
90
|
+
[--limit 50] [--search <name>] [--practice <id>]
|
|
84
91
|
dentist update <dentist-id> Edit fields on a seeded clinician
|
|
85
92
|
dentist images <dentist-id> Manage a clinician's photos
|
|
86
93
|
dentist pricing <dentist-id> Manage a clinician's treatment prices
|
|
87
94
|
dentist delete <dentist-id> Remove an unclaimed seeded clinician
|
|
88
95
|
[--unpublish] [--yes]
|
|
89
|
-
status [<
|
|
96
|
+
status [<profile-id>] List or inspect seeded profiles
|
|
97
|
+
[--dentist] [--full] [--limit 50] [--search <name>]
|
|
98
|
+
claims Review dashboard: pending claims + ready profiles
|
|
99
|
+
[--status pending|approved|rejected|all] [--dentist] [--ready] [--limit 50]
|
|
100
|
+
claims show <claim-id> Full detail for one claim
|
|
101
|
+
claims approve <claim-id> Hand the profile to the claimant
|
|
102
|
+
claims decline <claim-id> Decline a claim [--reason "..."]
|
|
90
103
|
|
|
91
104
|
${c.bold("Global")} --json --yes --verbose --operator <name> --version
|
|
92
105
|
`);
|
|
@@ -188,8 +201,45 @@ async function cmdProfileUpdate(type, id) {
|
|
|
188
201
|
if (res.rejected?.length) warn(`Ignored unknown field(s): ${res.rejected.join(", ")}`);
|
|
189
202
|
}
|
|
190
203
|
|
|
204
|
+
const MIME_BY_EXT = {
|
|
205
|
+
jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png",
|
|
206
|
+
webp: "image/webp", svg: "image/svg+xml", gif: "image/gif",
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/** A local path becomes base64; a URL is fetched server-side. */
|
|
210
|
+
function uploadPayload(src) {
|
|
211
|
+
const value = String(src);
|
|
212
|
+
if (/^https?:\/\//i.test(value)) return { source_url: value };
|
|
213
|
+
const ext = value.split(".").pop().toLowerCase();
|
|
214
|
+
return {
|
|
215
|
+
data: readFileSync(value).toString("base64"),
|
|
216
|
+
content_type: MIME_BY_EXT[ext] || "image/jpeg",
|
|
217
|
+
filename: value.split(/[\\/]/).pop(),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function uploadImages(type, id, sources, attach) {
|
|
222
|
+
const urls = [];
|
|
223
|
+
for (const src of sources) {
|
|
224
|
+
const s = spinner(`Uploading ${src}`);
|
|
225
|
+
try {
|
|
226
|
+
const res = await call("cli-profile-upload", { id, profile_type: type, attach, ...uploadPayload(src) });
|
|
227
|
+
s.stop();
|
|
228
|
+
urls.push(res.url);
|
|
229
|
+
if (!JSON_OUT) ok(`${attach === "logo" ? "Logo" : "Photo"} uploaded — ${c.dim(res.url)}`);
|
|
230
|
+
} catch (e) { s.stop(); throw e; }
|
|
231
|
+
}
|
|
232
|
+
return urls;
|
|
233
|
+
}
|
|
234
|
+
|
|
191
235
|
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); }
|
|
236
|
+
if (!id) { fail(`Usage: setu ${type} images <id> [--add <url>] [--upload <file>] [--remove <url|index>] [--order "a,b"] [--logo <url>] [--upload-logo <file>]`); process.exit(1); }
|
|
237
|
+
|
|
238
|
+
const uploads = splitList(flags.upload);
|
|
239
|
+
const logoUploads = splitList(flags["upload-logo"]);
|
|
240
|
+
const uploaded = [];
|
|
241
|
+
if (uploads.length) uploaded.push(...await uploadImages(type, id, uploads, "gallery"));
|
|
242
|
+
if (logoUploads.length) await uploadImages(type, id, logoUploads.slice(0, 1), "logo");
|
|
193
243
|
|
|
194
244
|
const images = {};
|
|
195
245
|
if (splitList(flags.add).length) images.add = splitList(flags.add);
|
|
@@ -521,34 +571,170 @@ async function cmdDentistCreate() {
|
|
|
521
571
|
town: typeof flags.town === "string" ? flags.town : undefined,
|
|
522
572
|
postcode: typeof flags.postcode === "string" ? flags.postcode : undefined,
|
|
523
573
|
email: typeof flags.email === "string" ? flags.email : undefined,
|
|
574
|
+
phone: typeof flags.phone === "string" ? flags.phone : undefined,
|
|
575
|
+
website_url: typeof flags.website === "string" ? flags.website : undefined,
|
|
576
|
+
booking_url: typeof flags.booking === "string" ? flags.booking : undefined,
|
|
577
|
+
bio: typeof flags.bio === "string" ? flags.bio : undefined,
|
|
578
|
+
photo_url: typeof flags.photo === "string" ? flags.photo : undefined,
|
|
579
|
+
gallery: splitList(flags.gallery),
|
|
524
580
|
});
|
|
525
581
|
s.stop();
|
|
526
582
|
if (JSON_OUT) return out(res);
|
|
527
583
|
ok("Clinician seeded as unclaimed");
|
|
528
|
-
table([
|
|
584
|
+
table([
|
|
585
|
+
["Dentist", res.dentist_id],
|
|
586
|
+
["AI enriched", res.enriched ? "yes" : "no"],
|
|
587
|
+
["Photo", res.photo_url ?? "—"],
|
|
588
|
+
["Claim link", res.claim_url],
|
|
589
|
+
]);
|
|
529
590
|
} catch (e) { s.stop(); throw e; }
|
|
530
591
|
}
|
|
531
592
|
|
|
532
|
-
|
|
533
|
-
|
|
593
|
+
function printProfileList(res) {
|
|
594
|
+
heading(`Seeded ${res.profile_type === "dentist" ? "clinicians" : "practices"} (${res.items.length})`);
|
|
595
|
+
for (const p of res.items) {
|
|
596
|
+
const state = p.claim_status === "claimed" ? c.green("claimed") : p.is_live ? c.green("live") : c.yellow(p.claim_status);
|
|
597
|
+
log(` ${state.padEnd(20)} ${String(p.name).slice(0, 38).padEnd(40)} ${c.dim(p.id)}`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async function cmdProfileList(type) {
|
|
602
|
+
const res = await call("cli-profile-status", {
|
|
603
|
+
list: true,
|
|
604
|
+
profile_type: type,
|
|
605
|
+
limit: flags.limit,
|
|
606
|
+
search: typeof flags.search === "string" ? flags.search : undefined,
|
|
607
|
+
practice_id: type === "dentist" && typeof flags.practice === "string" ? flags.practice : undefined,
|
|
608
|
+
});
|
|
534
609
|
if (JSON_OUT) return out(res);
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
610
|
+
printProfileList(res);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function cmdStatus(id, type = flags.dentist ? "dentist" : "practice") {
|
|
614
|
+
const res = await call(
|
|
615
|
+
"cli-profile-status",
|
|
616
|
+
id
|
|
617
|
+
? { id, profile_type: type, full: !!flags.full }
|
|
618
|
+
: { list: true, profile_type: type, limit: flags.limit, search: typeof flags.search === "string" ? flags.search : undefined },
|
|
619
|
+
);
|
|
620
|
+
if (JSON_OUT) return out(res);
|
|
621
|
+
if (res.items) return printProfileList(res);
|
|
622
|
+
|
|
623
|
+
const p = res.profile ?? res.practice;
|
|
624
|
+
heading(p.name);
|
|
625
|
+
table([
|
|
626
|
+
["Type", res.profile_type],
|
|
627
|
+
["Status", p.claim_status],
|
|
628
|
+
["Live", p.is_live ? "yes" : "no"],
|
|
629
|
+
["Source", `${p.source} (${p.created_by_operator ?? "—"})`],
|
|
630
|
+
["Claim link", p.claim_url],
|
|
631
|
+
]);
|
|
632
|
+
|
|
633
|
+
if (flags.full) {
|
|
634
|
+
const gallery = res.gallery ?? [];
|
|
635
|
+
heading(`Photos (${gallery.length})`);
|
|
636
|
+
gallery.forEach((url, i) => log(` ${String(i).padEnd(3)} ${c.dim(url)}`));
|
|
637
|
+
log(` ${c.dim(res.profile_type === "dentist" ? "main photo" : "logo")}: ${res.logo ?? c.dim("none")}`);
|
|
638
|
+
|
|
639
|
+
const pricing = res.pricing ?? [];
|
|
640
|
+
heading(`Prices (${pricing.length})`);
|
|
641
|
+
for (const row of pricing) {
|
|
642
|
+
const amount = row.amount_to ? `${row.amount}–${row.amount_to}` : row.amount ?? "—";
|
|
643
|
+
log(` ${String(row.label).slice(0, 38).padEnd(40)} ${amount}`);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (res.dentists) {
|
|
647
|
+
heading(`Clinicians (${res.dentists.length})`);
|
|
648
|
+
for (const d of res.dentists) log(` ${String(d.name).slice(0, 38).padEnd(40)} ${c.dim(d.id)}`);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
heading(`Claims (${res.claims.length})`);
|
|
653
|
+
for (const cl of res.claims) log(` ${cl.status.padEnd(10)} ${cl.claimant_email} ${c.dim(cl.method)}`);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function printClaimRow(cl) {
|
|
657
|
+
const who = String(cl.claimant_email ?? "—").slice(0, 30).padEnd(32);
|
|
658
|
+
const name = String(cl.profile?.name ?? cl.profile_type ?? "—").slice(0, 26).padEnd(28);
|
|
659
|
+
const flag = cl.awaiting_signin ? c.yellow(" (not signed in)") : "";
|
|
660
|
+
log(` ${String(cl.status).padEnd(9)} ${who} ${name} ${c.dim(cl.method ?? "")}${flag}`);
|
|
661
|
+
log(` ${c.dim(cl.id)}`);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
async function cmdClaims(sub, arg) {
|
|
665
|
+
if (sub === "show") {
|
|
666
|
+
if (!arg) throw new Error("Usage: setu claims show <claim-id>");
|
|
667
|
+
const res = await call("cli-claims", { action: "detail", claim_id: arg });
|
|
668
|
+
if (JSON_OUT) return out(res);
|
|
669
|
+
heading(`Claim ${res.claim.id}`);
|
|
670
|
+
table([
|
|
671
|
+
["Status", res.claim.status],
|
|
672
|
+
["Claimant", res.claim.claimant_email],
|
|
673
|
+
["Method", res.claim.method],
|
|
674
|
+
["Attempts", res.claim.attempts],
|
|
675
|
+
["Signed in", res.claim.claimant_user_id ? "yes" : "no"],
|
|
676
|
+
["Profile", res.profile ? `${res.profile.name} (${res.claim.profile_type})` : "—"],
|
|
677
|
+
["Readiness", res.profile ? `${res.profile.score}%` : "—"],
|
|
678
|
+
["Missing", res.profile?.missing?.join(", ")],
|
|
679
|
+
["Claim link", res.profile?.claim_url],
|
|
680
|
+
]);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if (sub === "approve" || sub === "decline" || sub === "reject") {
|
|
685
|
+
if (!arg) throw new Error(`Usage: setu claims ${sub} <claim-id>`);
|
|
686
|
+
if (!flags.yes && !JSON_OUT) {
|
|
687
|
+
const go = await confirm(`${sub === "approve" ? "Approve" : "Decline"} claim ${arg}?`);
|
|
688
|
+
if (!go) return warn("Cancelled.");
|
|
540
689
|
}
|
|
690
|
+
const res = await call("cli-claims", {
|
|
691
|
+
action: sub === "approve" ? "approve" : "decline",
|
|
692
|
+
claim_id: arg,
|
|
693
|
+
reason: typeof flags.reason === "string" ? flags.reason : undefined,
|
|
694
|
+
});
|
|
695
|
+
if (JSON_OUT) return out(res);
|
|
696
|
+
return ok(`Claim ${res.status} — ${res.claim_id}`);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (sub && sub !== "list") throw new Error(`Unknown command: claims ${sub}`);
|
|
700
|
+
|
|
701
|
+
const status = typeof flags.status === "string" ? flags.status : "pending";
|
|
702
|
+
const profileType = flags.dentist ? "dentist" : undefined;
|
|
703
|
+
if (sub === "list" || (typeof flags.status === "string" && flags.status !== "pending")) {
|
|
704
|
+
const res = await call("cli-claims", { action: "list", status, profile_type: profileType, limit: flags.limit });
|
|
705
|
+
if (JSON_OUT) return out(res);
|
|
706
|
+
heading(`Claims — ${status} (${res.claims.length})`);
|
|
707
|
+
res.claims.forEach(printClaimRow);
|
|
708
|
+
if (!res.claims.length) log(c.dim(" none"));
|
|
541
709
|
return;
|
|
542
710
|
}
|
|
543
|
-
|
|
711
|
+
|
|
712
|
+
const res = await call("cli-claims", {
|
|
713
|
+
action: "dashboard",
|
|
714
|
+
profile_type: profileType,
|
|
715
|
+
ready_only: !!flags.ready,
|
|
716
|
+
limit: flags.limit,
|
|
717
|
+
});
|
|
718
|
+
if (JSON_OUT) return out(res);
|
|
719
|
+
|
|
720
|
+
heading("Claims");
|
|
544
721
|
table([
|
|
545
|
-
["
|
|
546
|
-
["
|
|
547
|
-
["
|
|
548
|
-
["Claim link", res.practice.claim_url],
|
|
722
|
+
["Pending", res.counts.pending],
|
|
723
|
+
["Approved", res.counts.approved],
|
|
724
|
+
["Rejected", res.counts.rejected],
|
|
549
725
|
]);
|
|
550
|
-
|
|
551
|
-
|
|
726
|
+
|
|
727
|
+
heading(`Pending review (${res.pending_claims.length})`);
|
|
728
|
+
res.pending_claims.forEach(printClaimRow);
|
|
729
|
+
if (!res.pending_claims.length) log(c.dim(" nothing waiting"));
|
|
730
|
+
|
|
731
|
+
heading(`Unclaimed profiles — ${res.unclaimed.ready}/${res.unclaimed.total} ready, ${res.unclaimed.not_live} ready but offline`);
|
|
732
|
+
for (const u of res.unclaimed.items) {
|
|
733
|
+
const badge = u.ready ? c.green("ready") : c.yellow("gaps ");
|
|
734
|
+
log(` ${badge} ${String(u.readiness_score + "%").padStart(4)} ${String(u.name).slice(0, 30).padEnd(32)} ${c.dim(u.profile_type)}${u.is_live ? "" : c.dim(" offline")}`);
|
|
735
|
+
if (u.missing.length) log(` ${c.dim("missing: " + u.missing.join(", "))}`);
|
|
736
|
+
}
|
|
737
|
+
if (!res.unclaimed.items.length) log(c.dim(" none"));
|
|
552
738
|
}
|
|
553
739
|
|
|
554
740
|
async function main() {
|
|
@@ -564,6 +750,7 @@ async function main() {
|
|
|
564
750
|
case "logout": return cmdLogout();
|
|
565
751
|
case "whoami": return cmdWhoami();
|
|
566
752
|
case "status": return cmdStatus(sub);
|
|
753
|
+
case "claims": return cmdClaims(sub, arg);
|
|
567
754
|
case "practice":
|
|
568
755
|
switch (sub) {
|
|
569
756
|
case "create": return cmdPracticeCreate();
|
|
@@ -575,6 +762,7 @@ async function main() {
|
|
|
575
762
|
case "update": return cmdProfileUpdate("practice", arg);
|
|
576
763
|
case "images": return cmdProfileImages("practice", arg);
|
|
577
764
|
case "pricing": return cmdProfilePricing("practice", arg);
|
|
765
|
+
case "list": return cmdProfileList("practice");
|
|
578
766
|
default:
|
|
579
767
|
fail(`Unknown command: practice ${sub ?? ""}`.trim());
|
|
580
768
|
usage();
|
|
@@ -583,6 +771,8 @@ async function main() {
|
|
|
583
771
|
}
|
|
584
772
|
case "dentist":
|
|
585
773
|
if (sub === "create") return cmdDentistCreate();
|
|
774
|
+
if (sub === "list") return cmdProfileList("dentist");
|
|
775
|
+
if (sub === "status") return cmdStatus(arg, "dentist");
|
|
586
776
|
if (sub === "update") return cmdProfileUpdate("dentist", arg);
|
|
587
777
|
if (sub === "images") return cmdProfileImages("dentist", arg);
|
|
588
778
|
if (sub === "pricing") return cmdProfilePricing("dentist", arg);
|