@letterstory/cli 0.40.0 → 0.42.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/README.md +28 -0
- package/lib/cli.mjs +11 -1
- package/lib/commands/admin.mjs +129 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -367,6 +367,34 @@ The ledger behind Kernel Tuning's Experiments tab
|
|
|
367
367
|
(docs/architecture/kernel-experiment-agent.md): every question asked of the kernels,
|
|
368
368
|
how it was tested, and what we learned and why.
|
|
369
369
|
|
|
370
|
+
### Staff: the CRM pipeline
|
|
371
|
+
|
|
372
|
+
```bash
|
|
373
|
+
# Needs a STAFF key with crm:read (reads) and crm:write (writes). The same routes the
|
|
374
|
+
# crm.letter.dev front end calls with a staff JWT; any /api/superadmin/crm/* route takes
|
|
375
|
+
# the key as x-staff-api-key (GET → crm:read, anything else → crm:write).
|
|
376
|
+
export LETTERSTORY_STAFF_API_KEY=<your staff key with crm:read,crm:write>
|
|
377
|
+
|
|
378
|
+
letterstory admin crm signals --status new # the Inbound feed (unmatched signals)
|
|
379
|
+
letterstory admin crm record --source bookface --kind message \
|
|
380
|
+
--title "Asked about phantoms on Bookface" --name "Jane Doe" --company Acme --domain acme.com
|
|
381
|
+
letterstory admin crm record --source twitter --kind social_engagement \
|
|
382
|
+
--title "Liked our launch tweet" --name "Pat" --linkedin https://linkedin.com/in/pat
|
|
383
|
+
letterstory admin crm signal <signal-id> qualify # link/create the account
|
|
384
|
+
letterstory admin crm signal <signal-id> dismiss # the easy delete (kept, marked)
|
|
385
|
+
letterstory admin crm accounts --stage demo_scheduled # with likelihood
|
|
386
|
+
letterstory admin crm score <account-id> # recompute; prints the factors
|
|
387
|
+
letterstory admin crm slack-channels [--all] # Watchdog channels + the account each is linked to
|
|
388
|
+
letterstory admin crm link-slack <account-id> C0123ABC # the CRM's watch-only Slack link (`none` unlinks)
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
Every trigger — Cal.com bookings, Circleback recordings, Letterhome dinner RSVPs, RB2B
|
|
392
|
+
visits, and what an agent records here — goes through one intake (`ingestSignal`,
|
|
393
|
+
src/lib/crm/signals-intake.ts), so an agent's signal dedupes against a webhook's exactly
|
|
394
|
+
as two webhooks do. Qualified kinds (booking, call recording, dinner, message, form) link
|
|
395
|
+
or create the account; unqualified ones (a visit, a like) only attach to an existing
|
|
396
|
+
account and otherwise wait in the Inbound feed.
|
|
397
|
+
|
|
370
398
|
### Propose, then onboard a phantom
|
|
371
399
|
|
|
372
400
|
The Fleet wizard's Plan and Name screens as two commands: pick the beat, then the name,
|
package/lib/cli.mjs
CHANGED
|
@@ -52,7 +52,7 @@ import {
|
|
|
52
52
|
} from "./commands.mjs";
|
|
53
53
|
|
|
54
54
|
// Keep in sync with cli/package.json.
|
|
55
|
-
export const VERSION = "0.
|
|
55
|
+
export const VERSION = "0.42.0";
|
|
56
56
|
|
|
57
57
|
// Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
|
|
58
58
|
// can't accidentally swallow the id as --json's value.
|
|
@@ -556,6 +556,16 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
|
|
|
556
556
|
session (a trial per arm), linked to its
|
|
557
557
|
ledger entry. Written by \`npm run lab --
|
|
558
558
|
publish\` in the kernels repo.
|
|
559
|
+
admin crm signals [--status new] [--intent qualified] [--source rb2b] [--account <id>]
|
|
560
|
+
| record --title "…" [--source bookface] [--kind other] [--intent qualified]
|
|
561
|
+
[--name …] [--email …] [--company …] [--domain …] [--linkedin …] [--detail …]
|
|
562
|
+
| signal <id> qualify|dismiss|link [--account <id>]
|
|
563
|
+
| accounts [--stage lead] [--q acme] | account <id> | score <account-id>
|
|
564
|
+
The staff CRM's pipeline: the inbound signal
|
|
565
|
+
feed, recording a signal with no webhook
|
|
566
|
+
(Bookface, a tweet like), qualifying or
|
|
567
|
+
dismissing one, and the explained score.
|
|
568
|
+
Needs crm:read (reads) / crm:write (writes).
|
|
559
569
|
admin tuning-context [--q "<question>"] The experiment agent's context pack (JSON):
|
|
560
570
|
the pipeline as deployed, levers, measures,
|
|
561
571
|
goals, related history.
|
package/lib/commands/admin.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// admin experiments list|show|new|update|note|started|spend|conclude|abandon … (tuning:experiment)
|
|
8
8
|
// admin tuning-context [--q "<question>"] (tuning:experiment)
|
|
9
9
|
// admin tuning-import <brief-results.json> [--experiment <id>] (tuning:experiment)
|
|
10
|
+
// admin crm signals|record|signal|accounts|account|score|slack-channels|link-slack … (crm:read / crm:write)
|
|
10
11
|
// admin create-org --domain acme.com [--name "Acme"] [--test] [--no-key] [--key-name N]
|
|
11
12
|
// [--capabilities a,b] [--onboard] [--about "…"] [--sitemap <url>]
|
|
12
13
|
// [--cms <provider>] [--posts N] [--seers suggest|skip] [--no-visuals]
|
|
@@ -410,14 +411,141 @@ async function cmdAdminTuningContext(ctx) {
|
|
|
410
411
|
);
|
|
411
412
|
}
|
|
412
413
|
|
|
414
|
+
/* ------------------------------------------------------------ the staff CRM */
|
|
415
|
+
|
|
416
|
+
const CRM = "/api/superadmin/crm";
|
|
417
|
+
|
|
418
|
+
function signalLine(s) {
|
|
419
|
+
const who = [s.personName, s.companyName ?? s.companyDomain].filter(Boolean).join(" · ");
|
|
420
|
+
return `${s.id} [${s.status} · ${s.intent}] ${s.occurredAt.slice(0, 10)} ${s.source}/${s.kind} ${s.title}${who ? `\n ${who}` : ""}`;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function accountLine(a) {
|
|
424
|
+
const score = a.likelihood == null ? "—" : a.likelihood.toFixed(2);
|
|
425
|
+
return `${a.id} [${a.stage}] ${score} ${a.name}${a.domain ? ` (${a.domain})` : ""}${a.isYc ? " · YC" : ""}`;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* `admin crm …` — the headless half of the staff CRM, so a background agent can watch the
|
|
430
|
+
* pipeline and record the signals that have no webhook (Bookface, tweet likes, DMs).
|
|
431
|
+
* Reads need `crm:read` on the staff key, writes `crm:write`. Every write goes through the
|
|
432
|
+
* same ingestSignal / actOnSignal the webhooks and the app use.
|
|
433
|
+
*/
|
|
434
|
+
async function cmdAdminCrm(ctx) {
|
|
435
|
+
const [sub, id, action] = ctx.positionals;
|
|
436
|
+
const { flags, io } = ctx;
|
|
437
|
+
const json = flagBool(flags.json);
|
|
438
|
+
const out = (result, line) => (json ? printResult(io, flags, result) : io.log(line(result)));
|
|
439
|
+
switch (sub) {
|
|
440
|
+
case "signals": {
|
|
441
|
+
const params = new URLSearchParams(
|
|
442
|
+
compact({
|
|
443
|
+
status: flagStr(flags.status),
|
|
444
|
+
intent: flagStr(flags.intent),
|
|
445
|
+
source: flagStr(flags.source),
|
|
446
|
+
account_id: flagStr(flags.account),
|
|
447
|
+
limit: flagStr(flags.limit),
|
|
448
|
+
})
|
|
449
|
+
);
|
|
450
|
+
return out(await staffRequest(ctx, "GET", `${CRM}/signals?${params}`), (r) =>
|
|
451
|
+
r.signals.length ? r.signals.map(signalLine).join("\n") : "(no signals)"
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
case "record": {
|
|
455
|
+
const body = compact({
|
|
456
|
+
source: flagStr(flags.source) ?? "agent",
|
|
457
|
+
kind: flagStr(flags.kind) ?? "other",
|
|
458
|
+
intent: flagStr(flags.intent),
|
|
459
|
+
title: flagStr(flags.title),
|
|
460
|
+
person_name: flagStr(flags.name),
|
|
461
|
+
person_email: flagStr(flags.email),
|
|
462
|
+
person_title: flagStr(flags["person-title"]),
|
|
463
|
+
linkedin_url: flagStr(flags.linkedin),
|
|
464
|
+
company_name: flagStr(flags.company),
|
|
465
|
+
company_domain: flagStr(flags.domain),
|
|
466
|
+
detail: flagStr(flags.detail),
|
|
467
|
+
url: flagStr(flags.link),
|
|
468
|
+
external_id: flagStr(flags["external-id"]),
|
|
469
|
+
occurred_at: flagStr(flags.at),
|
|
470
|
+
account_id: flagStr(flags.account),
|
|
471
|
+
});
|
|
472
|
+
if (!body.title)
|
|
473
|
+
throw new CliError(
|
|
474
|
+
'Usage: admin crm record --title "Liked our launch tweet" [--source twitter] [--kind social_engagement] …'
|
|
475
|
+
);
|
|
476
|
+
const r = await staffPost(ctx, `${CRM}/signals`, body);
|
|
477
|
+
return out(r, (x) => `${x.duplicate ? "already recorded" : "recorded"}: ${signalLine(x.signal)}`);
|
|
478
|
+
}
|
|
479
|
+
case "signal": {
|
|
480
|
+
if (!id || !["qualify", "dismiss", "link"].includes(action)) {
|
|
481
|
+
throw new CliError("Usage: admin crm signal <id> qualify|dismiss|link [--account <account-id>]");
|
|
482
|
+
}
|
|
483
|
+
const r = await staffRequest(
|
|
484
|
+
ctx,
|
|
485
|
+
"PATCH",
|
|
486
|
+
`${CRM}/signals/${id}`,
|
|
487
|
+
compact({ action, account_id: flagStr(flags.account) })
|
|
488
|
+
);
|
|
489
|
+
return out(r, (x) => signalLine(x.signal));
|
|
490
|
+
}
|
|
491
|
+
case "accounts": {
|
|
492
|
+
const params = new URLSearchParams(compact({ stage: flagStr(flags.stage), q: flagStr(flags.q) }));
|
|
493
|
+
return out(await staffRequest(ctx, "GET", `${CRM}/accounts?${params}`), (r) =>
|
|
494
|
+
r.accounts.length ? r.accounts.map(accountLine).join("\n") : "(no accounts)"
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
case "account":
|
|
498
|
+
if (!id) throw new CliError("Usage: admin crm account <id>");
|
|
499
|
+
return printResult(io, { ...flags, json: true }, await staffRequest(ctx, "GET", `${CRM}/accounts/${id}`));
|
|
500
|
+
case "score": {
|
|
501
|
+
if (!id) throw new CliError("Usage: admin crm score <account-id>");
|
|
502
|
+
const r = await staffPost(ctx, `${CRM}/accounts/${id}/score`, {});
|
|
503
|
+
return out(r, (x) =>
|
|
504
|
+
[
|
|
505
|
+
accountLine(x.account),
|
|
506
|
+
...(x.account.scoreFactors ?? []).map(
|
|
507
|
+
(f) => ` ${f.direction === "risk" ? "−" : "+"}${f.weight.toFixed(2)} ${f.label}`
|
|
508
|
+
),
|
|
509
|
+
].join("\n")
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
case "slack-channels": {
|
|
513
|
+
const params = flagBool(flags.all) ? "?all=1" : "";
|
|
514
|
+
return out(await staffRequest(ctx, "GET", `${CRM}/slack-channels${params}`), (r) =>
|
|
515
|
+
r.channels.length
|
|
516
|
+
? r.channels
|
|
517
|
+
.map(
|
|
518
|
+
(c) =>
|
|
519
|
+
`${c.id} #${c.name ?? "?"}${c.linkedAccountId ? ` → ${c.linkedAccountId}` : ""}`
|
|
520
|
+
)
|
|
521
|
+
.join("\n")
|
|
522
|
+
: "(no channels)"
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
case "link-slack": {
|
|
526
|
+
// admin crm link-slack <account-id> <channel-id|none> — the CRM's watch-only link (never a binding).
|
|
527
|
+
if (!id || !action) throw new CliError("Usage: admin crm link-slack <account-id> <channel-id|none>");
|
|
528
|
+
const r = await staffRequest(ctx, "PATCH", `${CRM}/accounts/${id}`, {
|
|
529
|
+
slack_channel_id: action === "none" ? null : action,
|
|
530
|
+
});
|
|
531
|
+
return out(r, () => (action === "none" ? "unlinked" : `linked ${action}`));
|
|
532
|
+
}
|
|
533
|
+
default:
|
|
534
|
+
throw new CliError(
|
|
535
|
+
"Usage: admin crm signals|record|signal|accounts|account|score|slack-channels|link-slack … (see --help)"
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
413
540
|
export async function cmdAdmin(ctx) {
|
|
414
541
|
const sub = ctx.positionals[0];
|
|
542
|
+
if (sub === "crm") return cmdAdminCrm({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
415
543
|
if (sub === "create-org") return cmdAdminCreateOrg({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
416
544
|
if (sub === "ask") return cmdAdminAsk({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
417
545
|
if (sub === "experiments") return cmdAdminExperiments({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
418
546
|
if (sub === "tuning-context") return cmdAdminTuningContext({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
419
547
|
if (sub === "tuning-import") return cmdAdminTuningImport({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
420
548
|
throw new CliError(
|
|
421
|
-
'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" | admin experiments … | admin tuning-context (see --help)'
|
|
549
|
+
'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" | admin experiments … | admin crm … | admin tuning-context (see --help)'
|
|
422
550
|
);
|
|
423
551
|
}
|