@senso-ai/cli 0.11.0 → 0.12.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 +100 -22
- package/dist/cli.js +1297 -18
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -499,6 +499,29 @@ function registerOrgCommands(program2) {
|
|
|
499
499
|
process.exit(1);
|
|
500
500
|
}
|
|
501
501
|
});
|
|
502
|
+
org.command("set-runs").description("Toggle the org-wide runs master switch. Pause every scheduled prompt run and content-generation run, or re-enable them.").requiredOption("--enabled <bool>", "Set to true or false").action(async (cmdOpts) => {
|
|
503
|
+
const opts = program2.opts();
|
|
504
|
+
const raw = cmdOpts.enabled.toLowerCase();
|
|
505
|
+
if (raw !== "true" && raw !== "false") {
|
|
506
|
+
error("--enabled must be `true` or `false`.");
|
|
507
|
+
process.exit(1);
|
|
508
|
+
}
|
|
509
|
+
const enable = raw === "true";
|
|
510
|
+
try {
|
|
511
|
+
const data = await apiRequest({
|
|
512
|
+
method: "PATCH",
|
|
513
|
+
path: "/org/me/runs-enabled",
|
|
514
|
+
body: { enable_runs: enable },
|
|
515
|
+
apiKey: opts.apiKey,
|
|
516
|
+
baseUrl: opts.baseUrl
|
|
517
|
+
});
|
|
518
|
+
success(`Org-wide runs ${enable ? "enabled" : "disabled"}.`);
|
|
519
|
+
console.log(JSON.stringify(data, null, 2));
|
|
520
|
+
} catch (err) {
|
|
521
|
+
error(formatApiError(err));
|
|
522
|
+
process.exit(1);
|
|
523
|
+
}
|
|
524
|
+
});
|
|
502
525
|
}
|
|
503
526
|
|
|
504
527
|
// src/commands/users.ts
|
|
@@ -591,6 +614,50 @@ function registerUserCommands(program2) {
|
|
|
591
614
|
process.exit(1);
|
|
592
615
|
}
|
|
593
616
|
});
|
|
617
|
+
users.command("invite").description("Invite a brand-new user by email. Creates the user (in Clerk and Senso) and adds them to the organization with the given role. Use `roles list` to find a role_id. If the email already belongs to a Senso user, use `users invite-existing` instead.").requiredOption("--email <email>", "User's email address").requiredOption("--given-name <name>", "First name").requiredOption("--family-name <name>", "Last name").requiredOption("--role-id <uuid>", "Role to assign \u2014 resolve with `senso roles list`").option("--is-current", "Make this org the new user's current org").action(async (cmdOpts) => {
|
|
618
|
+
const opts = program2.opts();
|
|
619
|
+
try {
|
|
620
|
+
const data = await apiRequest({
|
|
621
|
+
method: "POST",
|
|
622
|
+
path: "/org/users/invite",
|
|
623
|
+
body: {
|
|
624
|
+
email: cmdOpts.email,
|
|
625
|
+
given_name: cmdOpts.givenName,
|
|
626
|
+
family_name: cmdOpts.familyName,
|
|
627
|
+
role_id: cmdOpts.roleId,
|
|
628
|
+
is_current: cmdOpts.isCurrent ?? false
|
|
629
|
+
},
|
|
630
|
+
apiKey: opts.apiKey,
|
|
631
|
+
baseUrl: opts.baseUrl
|
|
632
|
+
});
|
|
633
|
+
success(`Invited ${cmdOpts.email} to the organization.`);
|
|
634
|
+
console.log(JSON.stringify(data, null, 2));
|
|
635
|
+
} catch (err) {
|
|
636
|
+
error(formatApiError(err));
|
|
637
|
+
process.exit(1);
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
users.command("invite-existing").description("Add an existing Senso user to the organization by email. Returns 404 if no user with that email exists \u2014 use `users invite` for brand-new users.").requiredOption("--email <email>", "Email of an existing Senso user").requiredOption("--role-id <uuid>", "Role to assign \u2014 resolve with `senso roles list`").option("--is-current", "Make this org the user's current org").action(async (cmdOpts) => {
|
|
641
|
+
const opts = program2.opts();
|
|
642
|
+
try {
|
|
643
|
+
const data = await apiRequest({
|
|
644
|
+
method: "POST",
|
|
645
|
+
path: "/org/users/invite/existing",
|
|
646
|
+
body: {
|
|
647
|
+
email: cmdOpts.email,
|
|
648
|
+
role_id: cmdOpts.roleId,
|
|
649
|
+
is_current: cmdOpts.isCurrent ?? false
|
|
650
|
+
},
|
|
651
|
+
apiKey: opts.apiKey,
|
|
652
|
+
baseUrl: opts.baseUrl
|
|
653
|
+
});
|
|
654
|
+
success(`Added ${cmdOpts.email} to the organization.`);
|
|
655
|
+
console.log(JSON.stringify(data, null, 2));
|
|
656
|
+
} catch (err) {
|
|
657
|
+
error(formatApiError(err));
|
|
658
|
+
process.exit(1);
|
|
659
|
+
}
|
|
660
|
+
});
|
|
594
661
|
}
|
|
595
662
|
|
|
596
663
|
// src/commands/api-keys.ts
|
|
@@ -1163,7 +1230,7 @@ async function uploadToS3(url, buffer, contentType) {
|
|
|
1163
1230
|
const res = await fetch(url, {
|
|
1164
1231
|
method: "PUT",
|
|
1165
1232
|
headers: { "Content-Type": contentType },
|
|
1166
|
-
body: buffer
|
|
1233
|
+
body: new Uint8Array(buffer)
|
|
1167
1234
|
});
|
|
1168
1235
|
if (!res.ok) {
|
|
1169
1236
|
throw new Error(`S3 upload failed: ${res.status} ${res.statusText}`);
|
|
@@ -1391,12 +1458,40 @@ function registerContentCommands(program2) {
|
|
|
1391
1458
|
process.exit(1);
|
|
1392
1459
|
}
|
|
1393
1460
|
});
|
|
1394
|
-
content.command("verification").description("List content items in the verification workflow. Filter by editorial status (draft, review, rejected, published) to manage the review pipeline.").option("--limit <n>", "Maximum items to return").option("--offset <n>", "Number of items to skip (for pagination)").option("--search <query>", "Filter by title").option("--status <status>", "Filter by status: all, draft, review, rejected, published").action(async (cmdOpts) => {
|
|
1461
|
+
content.command("verification").description("List content items in the verification workflow. Filter by editorial status (draft, review, rejected, published) to manage the review pipeline.").option("--limit <n>", "Maximum items to return").option("--offset <n>", "Number of items to skip (for pagination)").option("--search <query>", "Filter by title").option("--status <status>", "Filter by status: all, draft, review, rejected, published").option("--substatus <substatus>", "Narrow further (only valid with --status published): pending_draft").action(async (cmdOpts) => {
|
|
1395
1462
|
const opts = program2.opts();
|
|
1396
1463
|
try {
|
|
1397
1464
|
const data = await apiRequest({
|
|
1398
1465
|
path: "/org/content/verification",
|
|
1399
|
-
params: { limit: cmdOpts.limit, offset: cmdOpts.offset, search: cmdOpts.search, status: cmdOpts.status },
|
|
1466
|
+
params: { limit: cmdOpts.limit, offset: cmdOpts.offset, search: cmdOpts.search, status: cmdOpts.status, substatus: cmdOpts.substatus },
|
|
1467
|
+
apiKey: opts.apiKey,
|
|
1468
|
+
baseUrl: opts.baseUrl
|
|
1469
|
+
});
|
|
1470
|
+
console.log(JSON.stringify(data, null, 2));
|
|
1471
|
+
} catch (err) {
|
|
1472
|
+
error(formatApiError(err));
|
|
1473
|
+
process.exit(1);
|
|
1474
|
+
}
|
|
1475
|
+
});
|
|
1476
|
+
content.command("verification-counts").description("Get counts of content by editorial status (draft, published, rejected, pending published-draft) plus per-destination published-domain summaries. A lightweight alternative to paging through 'content verification'.").action(async () => {
|
|
1477
|
+
const opts = program2.opts();
|
|
1478
|
+
try {
|
|
1479
|
+
const data = await apiRequest({
|
|
1480
|
+
path: "/org/content/verification/counts",
|
|
1481
|
+
apiKey: opts.apiKey,
|
|
1482
|
+
baseUrl: opts.baseUrl
|
|
1483
|
+
});
|
|
1484
|
+
console.log(JSON.stringify(data, null, 2));
|
|
1485
|
+
} catch (err) {
|
|
1486
|
+
error(formatApiError(err));
|
|
1487
|
+
process.exit(1);
|
|
1488
|
+
}
|
|
1489
|
+
});
|
|
1490
|
+
content.command("versions <id>").description("List the version history for a content item, newest first. The current version is flagged with is_current.").action(async (id) => {
|
|
1491
|
+
const opts = program2.opts();
|
|
1492
|
+
try {
|
|
1493
|
+
const data = await apiRequest({
|
|
1494
|
+
path: `/org/content/${id}/versions`,
|
|
1400
1495
|
apiKey: opts.apiKey,
|
|
1401
1496
|
baseUrl: opts.baseUrl
|
|
1402
1497
|
});
|
|
@@ -1669,12 +1764,12 @@ function registerGenerateCommands(program2) {
|
|
|
1669
1764
|
process.exit(1);
|
|
1670
1765
|
}
|
|
1671
1766
|
});
|
|
1672
|
-
gen.command("runs-items <runId>").description("List individual prompt items within a content generation run and their per-item status.").option("--limit <n>", "Items per page", "100").option("--offset <n>", "Pagination offset", "0").action(async (runId, cmdOpts) => {
|
|
1767
|
+
gen.command("runs-items <runId>").description("List individual prompt items within a content generation run and their per-item status.").option("--limit <n>", "Items per page", "100").option("--offset <n>", "Pagination offset", "0").option("--status <status>", "Filter by item status: pending, running, succeeded, failed, skipped, stopped").action(async (runId, cmdOpts) => {
|
|
1673
1768
|
const opts = program2.opts();
|
|
1674
1769
|
try {
|
|
1675
1770
|
const data = await apiRequest({
|
|
1676
1771
|
path: `/org/content-generation/runs/${runId}/items`,
|
|
1677
|
-
params: { limit: cmdOpts.limit, offset: cmdOpts.offset },
|
|
1772
|
+
params: { limit: cmdOpts.limit, offset: cmdOpts.offset, status: cmdOpts.status },
|
|
1678
1773
|
apiKey: opts.apiKey,
|
|
1679
1774
|
baseUrl: opts.baseUrl
|
|
1680
1775
|
});
|
|
@@ -1727,7 +1822,7 @@ function sleep(ms) {
|
|
|
1727
1822
|
// src/commands/engine.ts
|
|
1728
1823
|
function registerEngineCommands(program2) {
|
|
1729
1824
|
const engine = program2.command("engine").description("Publish or draft content through the content engine. Used to push AI-generated content to external destinations (citeables by default) or save it as a draft for review.");
|
|
1730
|
-
engine.command("publish").description("Publish content to external destinations via the content engine. Requires geo_question_id, raw_markdown, and seo_title. By default publishes to every destination currently selected for generation (citeables is the default for most orgs \u2014 see 'senso destinations list'). Pass --publisher-ids to restrict publishing to a specific subset, or include 'publisher_ids' inside --data.").requiredOption("--data <json>", 'JSON: { "geo_question_id": "uuid", "raw_markdown": "...", "seo_title": "...", "summary": "...", "publisher_ids": ["<uuid>", ...] }').option("--publisher-ids <ids...>", "Restrict publishing to specific publisher IDs. Overrides any publisher_ids present in --data. Omit to publish to all configured destinations (citeables by default).").action(async (cmdOpts) => {
|
|
1825
|
+
engine.command("publish").description("Publish content to external destinations via the content engine. Requires geo_question_id, raw_markdown, and seo_title. By default publishes to every destination currently selected for generation (citeables is the default for most orgs \u2014 see 'senso destinations list'). Pass --publisher-ids to restrict publishing to a specific subset, or include 'publisher_ids' inside --data. To record content as already published externally rather than pushing it to destinations, set mark_as_published (and optionally manual_published_at) in --data.").requiredOption("--data <json>", 'JSON: { "geo_question_id": "uuid", "raw_markdown": "...", "seo_title": "...", "summary": "...", "publisher_ids": ["<uuid>", ...], "mark_as_published": false, "manual_published_at": "2026-06-11T00:00:00Z" }').option("--publisher-ids <ids...>", "Restrict publishing to specific publisher IDs. Overrides any publisher_ids present in --data. Omit to publish to all configured destinations (citeables by default).").action(async (cmdOpts) => {
|
|
1731
1826
|
const opts = program2.opts();
|
|
1732
1827
|
try {
|
|
1733
1828
|
const body = JSON.parse(cmdOpts.data);
|
|
@@ -1847,14 +1942,13 @@ function registerPublishRecordsCommands(program2) {
|
|
|
1847
1942
|
pr.command("retry <publishRecordId>").description("Retry a failed publish record. Re-runs the publish for that specific content+destination pair and flips the record's state based on the new attempt. Only works on records currently in the 'failed' state.").action(async (publishRecordId) => {
|
|
1848
1943
|
const opts = program2.opts();
|
|
1849
1944
|
try {
|
|
1850
|
-
|
|
1945
|
+
await apiRequest({
|
|
1851
1946
|
method: "POST",
|
|
1852
1947
|
path: `/org/publish-records/${publishRecordId}/retry`,
|
|
1853
1948
|
apiKey: opts.apiKey,
|
|
1854
1949
|
baseUrl: opts.baseUrl
|
|
1855
1950
|
});
|
|
1856
|
-
success(`Publish record ${publishRecordId} retry
|
|
1857
|
-
console.log(JSON.stringify(data, null, 2));
|
|
1951
|
+
success(`Publish record ${publishRecordId} retry completed.`);
|
|
1858
1952
|
} catch (err) {
|
|
1859
1953
|
error(formatApiError(err));
|
|
1860
1954
|
process.exit(1);
|
|
@@ -1864,7 +1958,7 @@ function registerPublishRecordsCommands(program2) {
|
|
|
1864
1958
|
|
|
1865
1959
|
// src/commands/brand-kit.ts
|
|
1866
1960
|
function registerBrandKitCommands(program2) {
|
|
1867
|
-
const bk = program2.command("brand-kit").description("Manage the organization's brand kit guidelines
|
|
1961
|
+
const bk = program2.command("brand-kit").description("Manage the organization's brand kit guidelines that inform AI content generation about your brand voice, tone, and style. The guidelines object accepts a defined set of keys: brand_name, brand_domain, brand_description, voice_and_tone, author_persona, and global_writing_rules (array). Unknown keys are rejected.");
|
|
1868
1962
|
bk.command("get").description("Get the current brand kit guidelines.").action(async () => {
|
|
1869
1963
|
const opts = program2.opts();
|
|
1870
1964
|
try {
|
|
@@ -1931,7 +2025,7 @@ function registerContentTypeCommands(program2) {
|
|
|
1931
2025
|
process.exit(1);
|
|
1932
2026
|
}
|
|
1933
2027
|
});
|
|
1934
|
-
ct.command("create").description("Create a new content type. Requires a name and
|
|
2028
|
+
ct.command("create").description("Create a new content type. Requires a name and a config defining the output structure. config accepts a defined set of keys: template, template_spec, cta_text, cta_destination, writing_rules (array). Unknown keys are rejected.").requiredOption("--data <json>", 'JSON: { "name": "Blog Post", "config": { "template": "...", "cta_text": "...", "cta_destination": "...", "writing_rules": [] } }').action(async (cmdOpts) => {
|
|
1935
2029
|
const opts = program2.opts();
|
|
1936
2030
|
try {
|
|
1937
2031
|
const body = JSON.parse(cmdOpts.data);
|
|
@@ -2415,7 +2509,7 @@ function registerQuestionsCommands(program2) {
|
|
|
2415
2509
|
process.exit(1);
|
|
2416
2510
|
}
|
|
2417
2511
|
});
|
|
2418
|
-
questions.command("patch <questionId>").description("Partially update a question.
|
|
2512
|
+
questions.command("patch <questionId>").description("Partially update a question. Supports updating tag associations and/or the funnel stage (type). At least one of tag_ids or type must be provided.").requiredOption("--data <json>", 'JSON: { "tag_ids": ["<uuid>", ...], "type": "decision|consideration|awareness|evaluation" } \u2014 pass tag_ids: null to clear all tags').action(async (questionId, cmdOpts) => {
|
|
2419
2513
|
const opts = program2.opts();
|
|
2420
2514
|
try {
|
|
2421
2515
|
const body = JSON.parse(cmdOpts.data);
|
|
@@ -2482,7 +2576,7 @@ async function uploadToS32(url, buffer, contentType) {
|
|
|
2482
2576
|
const res = await fetch(url, {
|
|
2483
2577
|
method: "PUT",
|
|
2484
2578
|
headers: { "Content-Type": contentType },
|
|
2485
|
-
body: buffer
|
|
2579
|
+
body: new Uint8Array(buffer)
|
|
2486
2580
|
});
|
|
2487
2581
|
if (!res.ok) throw new Error(`S3 upload failed: ${res.status} ${res.statusText}`);
|
|
2488
2582
|
}
|
|
@@ -3021,14 +3115,1193 @@ function registerTagsCommands(program2) {
|
|
|
3021
3115
|
});
|
|
3022
3116
|
}
|
|
3023
3117
|
|
|
3024
|
-
// src/commands/
|
|
3025
|
-
|
|
3118
|
+
// src/commands/roles.ts
|
|
3119
|
+
function registerRolesCommands(program2) {
|
|
3120
|
+
const roles = program2.command("roles").description("Inspect the roles defined for your organization. Each organization has its own per-org role_ids \u2014 resolve a role name to its UUID here before passing role_id to `users invite`, `users add`, or `users update`.");
|
|
3121
|
+
roles.command("list").description("List every role for the current organization, including the built-in admin/collaborator/viewer roles and any custom roles.").action(async () => {
|
|
3122
|
+
const opts = program2.opts();
|
|
3123
|
+
try {
|
|
3124
|
+
const data = await apiRequest({
|
|
3125
|
+
path: "/org/roles",
|
|
3126
|
+
apiKey: opts.apiKey,
|
|
3127
|
+
baseUrl: opts.baseUrl
|
|
3128
|
+
});
|
|
3129
|
+
console.log(JSON.stringify(data, null, 2));
|
|
3130
|
+
} catch (err) {
|
|
3131
|
+
error(formatApiError(err));
|
|
3132
|
+
process.exit(1);
|
|
3133
|
+
}
|
|
3134
|
+
});
|
|
3135
|
+
}
|
|
3136
|
+
|
|
3137
|
+
// src/commands/competitors.ts
|
|
3138
|
+
function registerCompetitorsCommands(program2) {
|
|
3139
|
+
const competitors = program2.command("competitors").description("Manage the curated list of competitor brands your organization tracks. Tracked competitors feed downstream share-of-voice analytics and inform content-generation prompts.");
|
|
3140
|
+
competitors.command("list").description("List every tracked competitor for the current organization.").action(async () => {
|
|
3141
|
+
const opts = program2.opts();
|
|
3142
|
+
try {
|
|
3143
|
+
const data = await apiRequest({
|
|
3144
|
+
path: "/org/competitors",
|
|
3145
|
+
apiKey: opts.apiKey,
|
|
3146
|
+
baseUrl: opts.baseUrl
|
|
3147
|
+
});
|
|
3148
|
+
console.log(JSON.stringify(data, null, 2));
|
|
3149
|
+
} catch (err) {
|
|
3150
|
+
error(formatApiError(err));
|
|
3151
|
+
process.exit(1);
|
|
3152
|
+
}
|
|
3153
|
+
});
|
|
3154
|
+
competitors.command("add").description("Add a single tracked competitor.").requiredOption("--name <name>", "Competitor brand name").option("--url <url>", "Competitor website URL").action(async (cmdOpts) => {
|
|
3155
|
+
const opts = program2.opts();
|
|
3156
|
+
try {
|
|
3157
|
+
const body = { name: cmdOpts.name };
|
|
3158
|
+
if (cmdOpts.url) body.url = cmdOpts.url;
|
|
3159
|
+
const data = await apiRequest({
|
|
3160
|
+
method: "POST",
|
|
3161
|
+
path: "/org/competitors",
|
|
3162
|
+
body,
|
|
3163
|
+
apiKey: opts.apiKey,
|
|
3164
|
+
baseUrl: opts.baseUrl
|
|
3165
|
+
});
|
|
3166
|
+
success(`Tracked competitor "${cmdOpts.name}" added.`);
|
|
3167
|
+
console.log(JSON.stringify(data, null, 2));
|
|
3168
|
+
} catch (err) {
|
|
3169
|
+
error(formatApiError(err));
|
|
3170
|
+
process.exit(1);
|
|
3171
|
+
}
|
|
3172
|
+
});
|
|
3173
|
+
competitors.command("batch-add").description("Add up to 50 tracked competitors in one call. Designed for accepting AI-generated suggestions returned by `competitors suggest`.").requiredOption("--data <json>", 'JSON: { "items": [{ "name": "...", "url": "...", "source": "manual|suggested_run_text|suggested_web_search", "rationale": "...", "confidence": 0.85 }, ...] }').action(async (cmdOpts) => {
|
|
3174
|
+
const opts = program2.opts();
|
|
3175
|
+
try {
|
|
3176
|
+
const body = JSON.parse(cmdOpts.data);
|
|
3177
|
+
const data = await apiRequest({
|
|
3178
|
+
method: "POST",
|
|
3179
|
+
path: "/org/competitors/batch",
|
|
3180
|
+
body,
|
|
3181
|
+
apiKey: opts.apiKey,
|
|
3182
|
+
baseUrl: opts.baseUrl
|
|
3183
|
+
});
|
|
3184
|
+
success("Tracked competitors added.");
|
|
3185
|
+
console.log(JSON.stringify(data, null, 2));
|
|
3186
|
+
} catch (err) {
|
|
3187
|
+
error(err instanceof SyntaxError ? "Invalid JSON in --data" : formatApiError(err));
|
|
3188
|
+
process.exit(1);
|
|
3189
|
+
}
|
|
3190
|
+
});
|
|
3191
|
+
competitors.command("suggest").description("Get AI-generated competitor suggestions seeded from your org's website and recent prompt-run results. Pipe accepted suggestions into `competitors batch-add`.").action(async () => {
|
|
3192
|
+
const opts = program2.opts();
|
|
3193
|
+
try {
|
|
3194
|
+
const data = await apiRequest({
|
|
3195
|
+
method: "POST",
|
|
3196
|
+
path: "/org/competitors/suggest",
|
|
3197
|
+
apiKey: opts.apiKey,
|
|
3198
|
+
baseUrl: opts.baseUrl
|
|
3199
|
+
});
|
|
3200
|
+
console.log(JSON.stringify(data, null, 2));
|
|
3201
|
+
} catch (err) {
|
|
3202
|
+
error(formatApiError(err));
|
|
3203
|
+
process.exit(1);
|
|
3204
|
+
}
|
|
3205
|
+
});
|
|
3206
|
+
competitors.command("update <competitorId>").description("Update a tracked competitor's name or URL.").requiredOption("--name <name>", "Competitor brand name").option("--url <url>", "Competitor website URL").action(async (competitorId, cmdOpts) => {
|
|
3207
|
+
const opts = program2.opts();
|
|
3208
|
+
try {
|
|
3209
|
+
const body = { name: cmdOpts.name };
|
|
3210
|
+
if (cmdOpts.url) body.url = cmdOpts.url;
|
|
3211
|
+
const data = await apiRequest({
|
|
3212
|
+
method: "PUT",
|
|
3213
|
+
path: `/org/competitors/${competitorId}`,
|
|
3214
|
+
body,
|
|
3215
|
+
apiKey: opts.apiKey,
|
|
3216
|
+
baseUrl: opts.baseUrl
|
|
3217
|
+
});
|
|
3218
|
+
success(`Tracked competitor ${competitorId} updated.`);
|
|
3219
|
+
console.log(JSON.stringify(data, null, 2));
|
|
3220
|
+
} catch (err) {
|
|
3221
|
+
error(formatApiError(err));
|
|
3222
|
+
process.exit(1);
|
|
3223
|
+
}
|
|
3224
|
+
});
|
|
3225
|
+
competitors.command("delete <competitorId>").description("Remove a tracked competitor.").action(async (competitorId) => {
|
|
3226
|
+
const opts = program2.opts();
|
|
3227
|
+
try {
|
|
3228
|
+
await apiRequest({
|
|
3229
|
+
method: "DELETE",
|
|
3230
|
+
path: `/org/competitors/${competitorId}`,
|
|
3231
|
+
apiKey: opts.apiKey,
|
|
3232
|
+
baseUrl: opts.baseUrl
|
|
3233
|
+
});
|
|
3234
|
+
success(`Tracked competitor ${competitorId} removed.`);
|
|
3235
|
+
} catch (err) {
|
|
3236
|
+
error(formatApiError(err));
|
|
3237
|
+
process.exit(1);
|
|
3238
|
+
}
|
|
3239
|
+
});
|
|
3240
|
+
}
|
|
3241
|
+
|
|
3242
|
+
// src/commands/tracked-sources.ts
|
|
3243
|
+
var MATCH_TYPES = "domain | host | path_prefix | exact_url";
|
|
3244
|
+
var TIERS = "primary (Owned) | tracked | secondary (External)";
|
|
3245
|
+
var CATEGORIES = "affiliated_domain | published_content | social | press";
|
|
3246
|
+
function buildSourceBody(cmdOpts) {
|
|
3247
|
+
const body = {};
|
|
3248
|
+
if (cmdOpts.pattern !== void 0) body.pattern = cmdOpts.pattern;
|
|
3249
|
+
if (cmdOpts.matchType !== void 0) body.match_type = cmdOpts.matchType;
|
|
3250
|
+
if (cmdOpts.tier !== void 0) body.tier = cmdOpts.tier;
|
|
3251
|
+
if (cmdOpts.category !== void 0) body.category = cmdOpts.category;
|
|
3252
|
+
if (cmdOpts.label !== void 0) body.label = cmdOpts.label;
|
|
3253
|
+
if (cmdOpts.priority !== void 0) body.priority = Number(cmdOpts.priority);
|
|
3254
|
+
if (cmdOpts.active !== void 0) body.active = cmdOpts.active;
|
|
3255
|
+
return body;
|
|
3256
|
+
}
|
|
3257
|
+
function registerTrackedSourcesCommands(program2) {
|
|
3258
|
+
const sources = program2.command("tracked-sources").description("Manage citation-classification rules that tier each cited URL as Owned (primary), Tracked, or External (secondary). Tracked sources drive share-of-voice and citation analytics. Rules created from published content are read-only.");
|
|
3259
|
+
sources.command("list").description("List every tracked source rule for the current organization.").action(async () => {
|
|
3260
|
+
const opts = program2.opts();
|
|
3261
|
+
try {
|
|
3262
|
+
const data = await apiRequest({
|
|
3263
|
+
path: "/org/tracked-sources",
|
|
3264
|
+
apiKey: opts.apiKey,
|
|
3265
|
+
baseUrl: opts.baseUrl
|
|
3266
|
+
});
|
|
3267
|
+
console.log(JSON.stringify(data, null, 2));
|
|
3268
|
+
} catch (err) {
|
|
3269
|
+
error(formatApiError(err));
|
|
3270
|
+
process.exit(1);
|
|
3271
|
+
}
|
|
3272
|
+
});
|
|
3273
|
+
sources.command("add").description("Add a tracked source rule. New rules are always created active.").requiredOption("--pattern <pattern>", "Value to match cited URLs against, interpreted per --match-type").requiredOption("--match-type <type>", `Match strategy: ${MATCH_TYPES}`).requiredOption("--tier <tier>", `Classification tier: ${TIERS}`).option("--category <category>", `Optional sub-category (only meaningful for the 'tracked' tier): ${CATEGORIES}`).option("--label <label>", "Optional human-readable label").option("--priority <n>", "Optional ordering priority (integer)").action(async (cmdOpts) => {
|
|
3274
|
+
const opts = program2.opts();
|
|
3275
|
+
try {
|
|
3276
|
+
const data = await apiRequest({
|
|
3277
|
+
method: "POST",
|
|
3278
|
+
path: "/org/tracked-sources",
|
|
3279
|
+
body: buildSourceBody(cmdOpts),
|
|
3280
|
+
apiKey: opts.apiKey,
|
|
3281
|
+
baseUrl: opts.baseUrl
|
|
3282
|
+
});
|
|
3283
|
+
success(`Tracked source "${cmdOpts.pattern}" added.`);
|
|
3284
|
+
console.log(JSON.stringify(data, null, 2));
|
|
3285
|
+
} catch (err) {
|
|
3286
|
+
error(formatApiError(err));
|
|
3287
|
+
process.exit(1);
|
|
3288
|
+
}
|
|
3289
|
+
});
|
|
3290
|
+
sources.command("update <sourceId>").description("Replace a tracked source rule (PUT). Pattern, match type, and tier are required. Published rules are read-only.").requiredOption("--pattern <pattern>", "Value to match cited URLs against, interpreted per --match-type").requiredOption("--match-type <type>", `Match strategy: ${MATCH_TYPES}`).requiredOption("--tier <tier>", `Classification tier: ${TIERS}`).option("--category <category>", `Optional sub-category (only meaningful for the 'tracked' tier): ${CATEGORIES}`).option("--label <label>", "Optional human-readable label").option("--priority <n>", "Optional ordering priority (integer)").option("--active", "Mark the rule active").option("--no-active", "Mark the rule inactive").action(async (sourceId, cmdOpts) => {
|
|
3291
|
+
const opts = program2.opts();
|
|
3292
|
+
try {
|
|
3293
|
+
const data = await apiRequest({
|
|
3294
|
+
method: "PUT",
|
|
3295
|
+
path: `/org/tracked-sources/${sourceId}`,
|
|
3296
|
+
body: buildSourceBody(cmdOpts),
|
|
3297
|
+
apiKey: opts.apiKey,
|
|
3298
|
+
baseUrl: opts.baseUrl
|
|
3299
|
+
});
|
|
3300
|
+
success(`Tracked source ${sourceId} updated.`);
|
|
3301
|
+
console.log(JSON.stringify(data, null, 2));
|
|
3302
|
+
} catch (err) {
|
|
3303
|
+
error(formatApiError(err));
|
|
3304
|
+
process.exit(1);
|
|
3305
|
+
}
|
|
3306
|
+
});
|
|
3307
|
+
sources.command("delete <sourceId>").description("Remove a tracked source rule.").action(async (sourceId) => {
|
|
3308
|
+
const opts = program2.opts();
|
|
3309
|
+
try {
|
|
3310
|
+
await apiRequest({
|
|
3311
|
+
method: "DELETE",
|
|
3312
|
+
path: `/org/tracked-sources/${sourceId}`,
|
|
3313
|
+
apiKey: opts.apiKey,
|
|
3314
|
+
baseUrl: opts.baseUrl
|
|
3315
|
+
});
|
|
3316
|
+
success(`Tracked source ${sourceId} removed.`);
|
|
3317
|
+
} catch (err) {
|
|
3318
|
+
error(formatApiError(err));
|
|
3319
|
+
process.exit(1);
|
|
3320
|
+
}
|
|
3321
|
+
});
|
|
3322
|
+
}
|
|
3323
|
+
|
|
3324
|
+
// src/commands/generated-content.ts
|
|
3325
|
+
function registerGeneratedContentCommands(program2) {
|
|
3326
|
+
const gc = program2.command("generated-content").description("Browse AI-generated content (GEO). List published or draft generated items, or fetch a single item with its rendered body. Requires the GEO product and read:content permission.");
|
|
3327
|
+
gc.command("list").description("List generated content. Use --status to switch between published and draft items.").option("--status <status>", "Which items to list: published | drafts", "published").option("--limit <n>", "Items per page (max 100)", "10").option("--offset <n>", "Pagination offset", "0").option("--search <query>", "Filter by title").action(async (cmdOpts) => {
|
|
3328
|
+
const opts = program2.opts();
|
|
3329
|
+
const status = cmdOpts.status === "drafts" || cmdOpts.status === "draft" ? "drafts" : "published";
|
|
3330
|
+
try {
|
|
3331
|
+
const data = await apiRequest({
|
|
3332
|
+
path: `/org/generated-content/${status}`,
|
|
3333
|
+
params: { limit: cmdOpts.limit, offset: cmdOpts.offset, search: cmdOpts.search },
|
|
3334
|
+
apiKey: opts.apiKey,
|
|
3335
|
+
baseUrl: opts.baseUrl
|
|
3336
|
+
});
|
|
3337
|
+
console.log(JSON.stringify(data, null, 2));
|
|
3338
|
+
} catch (err) {
|
|
3339
|
+
error(formatApiError(err));
|
|
3340
|
+
process.exit(1);
|
|
3341
|
+
}
|
|
3342
|
+
});
|
|
3343
|
+
gc.command("get <id>").description("Get a single generated content item including its question text and rendered body.").action(async (id) => {
|
|
3344
|
+
const opts = program2.opts();
|
|
3345
|
+
try {
|
|
3346
|
+
const data = await apiRequest({
|
|
3347
|
+
path: `/org/generated-content/${id}`,
|
|
3348
|
+
apiKey: opts.apiKey,
|
|
3349
|
+
baseUrl: opts.baseUrl
|
|
3350
|
+
});
|
|
3351
|
+
console.log(JSON.stringify(data, null, 2));
|
|
3352
|
+
} catch (err) {
|
|
3353
|
+
error(formatApiError(err));
|
|
3354
|
+
process.exit(1);
|
|
3355
|
+
}
|
|
3356
|
+
});
|
|
3357
|
+
}
|
|
3358
|
+
|
|
3359
|
+
// src/commands/analytics.ts
|
|
3026
3360
|
import pc10 from "picocolors";
|
|
3361
|
+
var NO_VALUE = "\u2014";
|
|
3362
|
+
function rate(r) {
|
|
3363
|
+
return r && typeof r.display === "string" ? r.display : NO_VALUE;
|
|
3364
|
+
}
|
|
3365
|
+
function count(v) {
|
|
3366
|
+
return v === void 0 || v === null ? NO_VALUE : v.toLocaleString("en-US");
|
|
3367
|
+
}
|
|
3368
|
+
function position(v) {
|
|
3369
|
+
return v === void 0 || v === null ? NO_VALUE : `#${v.toFixed(1)}`;
|
|
3370
|
+
}
|
|
3371
|
+
function trend(t) {
|
|
3372
|
+
return t ? `${t.display} (${t.direction})` : NO_VALUE;
|
|
3373
|
+
}
|
|
3374
|
+
function ratio(numerator, denominator, unit) {
|
|
3375
|
+
return `${count(numerator)} / ${count(denominator)} ${unit}`;
|
|
3376
|
+
}
|
|
3377
|
+
function truncate(value, max) {
|
|
3378
|
+
const flat = (value ?? "").replace(/\s+/g, " ").trim();
|
|
3379
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}\u2026` : flat;
|
|
3380
|
+
}
|
|
3381
|
+
function windowLine(w) {
|
|
3382
|
+
if (!w) return "";
|
|
3383
|
+
const fresh = w.latest_data_day ? `, latest data ${w.latest_data_day}` : ", no data days in window";
|
|
3384
|
+
return ` ${pc10.dim(`Window ${w.from} \u2192 ${w.to} (${w.days} days${fresh})`)}`;
|
|
3385
|
+
}
|
|
3386
|
+
function qualityLine(dq) {
|
|
3387
|
+
if (!dq) return "";
|
|
3388
|
+
return ` ${pc10.dim(`Data quality: ${dq.level} \u2014 ${count(dq.answered_count)} answered runs`)}`;
|
|
3389
|
+
}
|
|
3390
|
+
function emitContext(format, lines) {
|
|
3391
|
+
if (format !== "table") return;
|
|
3392
|
+
const visible = lines.filter(Boolean);
|
|
3393
|
+
if (visible.length === 0) return;
|
|
3394
|
+
outputPlain([...visible, ""]);
|
|
3395
|
+
}
|
|
3396
|
+
function emitNotes(format, notes) {
|
|
3397
|
+
if (format === "json" || !notes || notes.length === 0) return;
|
|
3398
|
+
outputPlain([
|
|
3399
|
+
"",
|
|
3400
|
+
` ${pc10.bold("Notes")}`,
|
|
3401
|
+
...notes.map((note) => ` ${pc10.dim("\u2022")} ${note}`)
|
|
3402
|
+
]);
|
|
3403
|
+
}
|
|
3404
|
+
function windowParams(o) {
|
|
3405
|
+
return {
|
|
3406
|
+
from: o.from,
|
|
3407
|
+
to: o.to,
|
|
3408
|
+
models: o.models,
|
|
3409
|
+
location: o.location,
|
|
3410
|
+
prompt_type: o.promptType,
|
|
3411
|
+
tag: o.tag
|
|
3412
|
+
};
|
|
3413
|
+
}
|
|
3414
|
+
function addWindowOptions(cmd, opts = {}) {
|
|
3415
|
+
const withTag = opts.tag !== false;
|
|
3416
|
+
const base = cmd.option("--from <date>", "Window start, YYYY-MM-DD (default: 30 days ending at the most recent day with data)").option("--to <date>", "Window end, YYYY-MM-DD (max window: 365 days)").option("--models <list>", "Comma-separated model filter \u2014 see 'senso analytics filters'").option("--location <list>", "Comma-separated location filter, case-sensitive (e.g. US, US/California)").option("--prompt-type <type>", "Funnel stage: awareness | consideration | evaluation | decision");
|
|
3417
|
+
return withTag ? base.option("--tag <tag>", "Restrict to prompts carrying this tag") : base;
|
|
3418
|
+
}
|
|
3419
|
+
function addPagingOptions(cmd, defaultLimit) {
|
|
3420
|
+
return cmd.option("--limit <n>", `Maximum rows to return (default: ${defaultLimit}, max: 100)`).option("--offset <n>", "Rows to skip (for pagination)");
|
|
3421
|
+
}
|
|
3422
|
+
var BOOL_VALUES = /* @__PURE__ */ new Set(["true", "false", "1", "0", "yes", "no"]);
|
|
3423
|
+
function normalizeBool(flag, raw) {
|
|
3424
|
+
if (raw === void 0) return void 0;
|
|
3425
|
+
const value = raw.trim().toLowerCase();
|
|
3426
|
+
if (!BOOL_VALUES.has(value)) {
|
|
3427
|
+
error(`Invalid --${flag}: expected true or false.`);
|
|
3428
|
+
process.exit(1);
|
|
3429
|
+
}
|
|
3430
|
+
return value;
|
|
3431
|
+
}
|
|
3432
|
+
function metricRows(totals, metrics, deltas) {
|
|
3433
|
+
return [
|
|
3434
|
+
{
|
|
3435
|
+
metric: "Mention Rate",
|
|
3436
|
+
value: rate(metrics.mention_rate),
|
|
3437
|
+
counts: ratio(totals.mentioned_count, totals.answered_count, "answers"),
|
|
3438
|
+
"vs prev": trend(deltas?.mention_rate)
|
|
3439
|
+
},
|
|
3440
|
+
{
|
|
3441
|
+
metric: "Share of Voice",
|
|
3442
|
+
value: rate(metrics.share_of_voice),
|
|
3443
|
+
counts: ratio(totals.mention_total, totals.brand_mention_total, "mentions (all brands)"),
|
|
3444
|
+
"vs prev": trend(deltas?.share_of_voice)
|
|
3445
|
+
},
|
|
3446
|
+
{
|
|
3447
|
+
metric: "Avg Rank",
|
|
3448
|
+
value: rate(metrics.avg_rank),
|
|
3449
|
+
counts: `over ${count(totals.mentioned_count)} mentioned answers`,
|
|
3450
|
+
"vs prev": NO_VALUE
|
|
3451
|
+
},
|
|
3452
|
+
{
|
|
3453
|
+
metric: "Citation Rate (Owned)",
|
|
3454
|
+
value: rate(metrics.primary_citation_rate),
|
|
3455
|
+
counts: ratio(totals.primary_cited_run_count, totals.cited_run_count, "cited answers"),
|
|
3456
|
+
"vs prev": trend(deltas?.primary_citation_rate)
|
|
3457
|
+
},
|
|
3458
|
+
{
|
|
3459
|
+
metric: "Citation Rate (Tracked)",
|
|
3460
|
+
value: rate(metrics.tracked_citation_rate),
|
|
3461
|
+
counts: ratio(totals.tracked_cited_run_count, totals.cited_run_count, "cited answers"),
|
|
3462
|
+
"vs prev": NO_VALUE
|
|
3463
|
+
},
|
|
3464
|
+
{
|
|
3465
|
+
metric: "Citation Rate (External)",
|
|
3466
|
+
value: rate(metrics.external_citation_rate),
|
|
3467
|
+
counts: ratio(totals.external_cited_run_count, totals.cited_run_count, "cited answers"),
|
|
3468
|
+
"vs prev": NO_VALUE
|
|
3469
|
+
},
|
|
3470
|
+
{
|
|
3471
|
+
metric: "Citation Share (Owned)",
|
|
3472
|
+
value: rate(metrics.primary_citation_share),
|
|
3473
|
+
counts: ratio(totals.primary_cited_total, totals.cited_total, "citations"),
|
|
3474
|
+
"vs prev": NO_VALUE
|
|
3475
|
+
},
|
|
3476
|
+
{
|
|
3477
|
+
metric: "Citation Share (Tracked)",
|
|
3478
|
+
value: rate(metrics.tracked_citation_share),
|
|
3479
|
+
counts: ratio(totals.tracked_cited_total, totals.cited_total, "citations"),
|
|
3480
|
+
"vs prev": NO_VALUE
|
|
3481
|
+
},
|
|
3482
|
+
{
|
|
3483
|
+
metric: "Citation Share (External)",
|
|
3484
|
+
value: rate(metrics.external_citation_share),
|
|
3485
|
+
counts: ratio(totals.external_cited_total, totals.cited_total, "citations"),
|
|
3486
|
+
"vs prev": NO_VALUE
|
|
3487
|
+
},
|
|
3488
|
+
{
|
|
3489
|
+
metric: "Citations per Answer",
|
|
3490
|
+
value: rate(metrics.citations_per_answer),
|
|
3491
|
+
counts: ratio(totals.cited_total, totals.cited_run_count, "cited answers"),
|
|
3492
|
+
"vs prev": NO_VALUE
|
|
3493
|
+
},
|
|
3494
|
+
{
|
|
3495
|
+
metric: "Sentiment (pos/neu/neg)",
|
|
3496
|
+
value: `${count(totals.sentiment?.positive)} / ${count(totals.sentiment?.neutral)} / ${count(totals.sentiment?.negative)}`,
|
|
3497
|
+
counts: `sums to ${count(totals.mentioned_count)} mentioned answers`,
|
|
3498
|
+
"vs prev": NO_VALUE
|
|
3499
|
+
}
|
|
3500
|
+
];
|
|
3501
|
+
}
|
|
3502
|
+
var METRIC_COLUMNS = ["metric", "value", "counts", "vs prev"];
|
|
3503
|
+
function metricPlainLines(rows) {
|
|
3504
|
+
const width = rows.reduce((max, r) => Math.max(max, String(r.metric).length), 0);
|
|
3505
|
+
return rows.map(
|
|
3506
|
+
(r) => ` ${pc10.bold(String(r.metric).padEnd(width))} ${String(r.value)} ${pc10.dim(`(${r.counts})`)}` + (r["vs prev"] !== NO_VALUE ? ` ${pc10.dim(`vs prev: ${r["vs prev"]}`)}` : "")
|
|
3507
|
+
);
|
|
3508
|
+
}
|
|
3509
|
+
function registerAnalyticsCommands(program2) {
|
|
3510
|
+
const analytics = program2.command("analytics").description(
|
|
3511
|
+
"GEO analytics for your organization \u2014 brand visibility, share of voice, and citations across the AI models you monitor. Every payload ships raw counts alongside the rates, and a rate is null (shown as \u201C\u2014\u201D) when its denominator is zero, never a silent 0%. Run 'senso analytics glossary' for the canonical definition and denominator of every metric."
|
|
3512
|
+
);
|
|
3513
|
+
addWindowOptions(
|
|
3514
|
+
analytics.command("summary").description(
|
|
3515
|
+
"One-call dashboard: every headline metric with its raw counts, plus the preceding equal-length window and the deltas between them."
|
|
3516
|
+
)
|
|
3517
|
+
).action(async (cmdOpts) => {
|
|
3518
|
+
const opts = program2.opts();
|
|
3519
|
+
const format = opts.output || "plain";
|
|
3520
|
+
try {
|
|
3521
|
+
const data = await apiRequest({
|
|
3522
|
+
path: "/org/analytics/summary",
|
|
3523
|
+
params: windowParams(cmdOpts),
|
|
3524
|
+
apiKey: opts.apiKey,
|
|
3525
|
+
baseUrl: opts.baseUrl
|
|
3526
|
+
});
|
|
3527
|
+
const rows = metricRows(data.totals, data.metrics, data.deltas);
|
|
3528
|
+
emitContext(format, [
|
|
3529
|
+
"",
|
|
3530
|
+
` ${pc10.bold("Analytics summary")}`,
|
|
3531
|
+
windowLine(data.window),
|
|
3532
|
+
qualityLine(data.data_quality)
|
|
3533
|
+
]);
|
|
3534
|
+
output(format, {
|
|
3535
|
+
json: data,
|
|
3536
|
+
table: { rows, columns: METRIC_COLUMNS },
|
|
3537
|
+
plain: [
|
|
3538
|
+
"",
|
|
3539
|
+
` ${pc10.bold("Analytics summary")}`,
|
|
3540
|
+
windowLine(data.window),
|
|
3541
|
+
qualityLine(data.data_quality),
|
|
3542
|
+
"",
|
|
3543
|
+
...metricPlainLines(rows),
|
|
3544
|
+
"",
|
|
3545
|
+
` ${pc10.dim(`Monitoring ${count(data.totals.prompt_count)} prompts \xD7 ${count(data.totals.model_count)} models \xD7 ${count(data.totals.location_count)} locations \u2014 ${count(data.totals.answered_count)} of ${count(data.totals.run_count)} runs answered`)}`
|
|
3546
|
+
]
|
|
3547
|
+
});
|
|
3548
|
+
emitNotes(format, data.notes);
|
|
3549
|
+
} catch (err) {
|
|
3550
|
+
error(formatApiError(err));
|
|
3551
|
+
process.exit(1);
|
|
3552
|
+
}
|
|
3553
|
+
});
|
|
3554
|
+
addWindowOptions(
|
|
3555
|
+
analytics.command("mentions").description(
|
|
3556
|
+
"Visibility time series: mention counts, share of voice (your mentions \xF7 mentions of every brand), average rank and sentiment, bucketed by day or week."
|
|
3557
|
+
)
|
|
3558
|
+
).option("--group-by <bucket>", "Time bucket: day | week (default: day)").action(async (cmdOpts) => {
|
|
3559
|
+
const opts = program2.opts();
|
|
3560
|
+
const format = opts.output || "plain";
|
|
3561
|
+
try {
|
|
3562
|
+
const data = await apiRequest({
|
|
3563
|
+
path: "/org/analytics/mentions",
|
|
3564
|
+
params: { ...windowParams(cmdOpts), group_by: cmdOpts.groupBy },
|
|
3565
|
+
apiKey: opts.apiKey,
|
|
3566
|
+
baseUrl: opts.baseUrl
|
|
3567
|
+
});
|
|
3568
|
+
const series = data.series ?? [];
|
|
3569
|
+
const context = [
|
|
3570
|
+
"",
|
|
3571
|
+
` ${pc10.bold("Mentions")} ${pc10.dim(`by ${data.group_by}`)}`,
|
|
3572
|
+
windowLine(data.window),
|
|
3573
|
+
qualityLine(data.data_quality),
|
|
3574
|
+
` ${pc10.dim(`Window totals \u2014 mention rate ${rate(data.metrics.mention_rate)}, share of voice ${rate(data.metrics.share_of_voice)}, avg rank ${rate(data.metrics.avg_rank)}`)}`
|
|
3575
|
+
];
|
|
3576
|
+
emitContext(format, context);
|
|
3577
|
+
output(format, {
|
|
3578
|
+
json: data,
|
|
3579
|
+
table: {
|
|
3580
|
+
rows: series.map((p4) => ({
|
|
3581
|
+
period: p4.period_start,
|
|
3582
|
+
answered: count(p4.answered_count),
|
|
3583
|
+
mentioned: count(p4.mentioned_count),
|
|
3584
|
+
mention_rate: rate(p4.mention_rate),
|
|
3585
|
+
sov: rate(p4.share_of_voice),
|
|
3586
|
+
avg_rank: rate(p4.avg_rank)
|
|
3587
|
+
})),
|
|
3588
|
+
columns: ["period", "answered", "mentioned", "mention_rate", "sov", "avg_rank"]
|
|
3589
|
+
},
|
|
3590
|
+
plain: [
|
|
3591
|
+
...context,
|
|
3592
|
+
"",
|
|
3593
|
+
...series.length ? series.map(
|
|
3594
|
+
(p4) => ` ${pc10.bold(p4.period_start)} answered ${count(p4.answered_count)} mentioned ${count(p4.mentioned_count)} rate ${rate(p4.mention_rate)} SoV ${rate(p4.share_of_voice)} rank ${rate(p4.avg_rank)}`
|
|
3595
|
+
) : [" No rollup days in this window."]
|
|
3596
|
+
]
|
|
3597
|
+
});
|
|
3598
|
+
emitNotes(format, data.notes);
|
|
3599
|
+
} catch (err) {
|
|
3600
|
+
error(formatApiError(err));
|
|
3601
|
+
process.exit(1);
|
|
3602
|
+
}
|
|
3603
|
+
});
|
|
3604
|
+
addWindowOptions(
|
|
3605
|
+
analytics.command("citations").description(
|
|
3606
|
+
"Citation overview: both denominators (D = cited answers, S = citation instances), every tier numerator, the tier rates (\xF7D) and tier shares (\xF7S), and the series underneath."
|
|
3607
|
+
)
|
|
3608
|
+
).option("--group-by <bucket>", "Time bucket: day | week (default: day)").action(async (cmdOpts) => {
|
|
3609
|
+
const opts = program2.opts();
|
|
3610
|
+
const format = opts.output || "plain";
|
|
3611
|
+
try {
|
|
3612
|
+
const data = await apiRequest({
|
|
3613
|
+
path: "/org/analytics/citations",
|
|
3614
|
+
params: { ...windowParams(cmdOpts), group_by: cmdOpts.groupBy },
|
|
3615
|
+
apiKey: opts.apiKey,
|
|
3616
|
+
baseUrl: opts.baseUrl
|
|
3617
|
+
});
|
|
3618
|
+
const series = data.series ?? [];
|
|
3619
|
+
const context = [
|
|
3620
|
+
"",
|
|
3621
|
+
` ${pc10.bold("Citations")} ${pc10.dim(`by ${data.group_by}`)}`,
|
|
3622
|
+
windowLine(data.window),
|
|
3623
|
+
qualityLine(data.data_quality),
|
|
3624
|
+
` ${pc10.dim(`D = ${count(data.totals.cited_run_count)} cited answers, S = ${count(data.totals.cited_total)} citation instances`)}`,
|
|
3625
|
+
` ${pc10.dim(`Owned rate ${rate(data.metrics.primary_citation_rate)} (\xF7D) \xB7 Owned share ${rate(data.metrics.primary_citation_share)} (\xF7S) \xB7 ${rate(data.metrics.citations_per_answer)}`)}`
|
|
3626
|
+
];
|
|
3627
|
+
emitContext(format, context);
|
|
3628
|
+
output(format, {
|
|
3629
|
+
json: data,
|
|
3630
|
+
table: {
|
|
3631
|
+
rows: series.map((p4) => ({
|
|
3632
|
+
period: p4.period_start,
|
|
3633
|
+
cited_answers: count(p4.cited_run_count),
|
|
3634
|
+
citations: count(p4.cited_total),
|
|
3635
|
+
owned_rate: rate(p4.primary_citation_rate),
|
|
3636
|
+
tracked_rate: rate(p4.tracked_citation_rate),
|
|
3637
|
+
external_rate: rate(p4.external_citation_rate),
|
|
3638
|
+
owned_share: rate(p4.primary_citation_share)
|
|
3639
|
+
})),
|
|
3640
|
+
columns: [
|
|
3641
|
+
"period",
|
|
3642
|
+
"cited_answers",
|
|
3643
|
+
"citations",
|
|
3644
|
+
"owned_rate",
|
|
3645
|
+
"tracked_rate",
|
|
3646
|
+
"external_rate",
|
|
3647
|
+
"owned_share"
|
|
3648
|
+
]
|
|
3649
|
+
},
|
|
3650
|
+
plain: [
|
|
3651
|
+
...context,
|
|
3652
|
+
"",
|
|
3653
|
+
...series.length ? series.map(
|
|
3654
|
+
(p4) => ` ${pc10.bold(p4.period_start)} cited answers ${count(p4.cited_run_count)} citations ${count(p4.cited_total)} owned ${rate(p4.primary_citation_rate)} tracked ${rate(p4.tracked_citation_rate)} external ${rate(p4.external_citation_rate)}`
|
|
3655
|
+
) : [" No rollup days in this window."]
|
|
3656
|
+
]
|
|
3657
|
+
});
|
|
3658
|
+
emitNotes(format, data.notes);
|
|
3659
|
+
} catch (err) {
|
|
3660
|
+
error(formatApiError(err));
|
|
3661
|
+
process.exit(1);
|
|
3662
|
+
}
|
|
3663
|
+
});
|
|
3664
|
+
addPagingOptions(
|
|
3665
|
+
addWindowOptions(
|
|
3666
|
+
analytics.command("domains").description(
|
|
3667
|
+
"Every domain the models cited, ranked. Citation Coverage is this domain's cited answers \xF7 D; Citation Share is its citation instances \xF7 S. Tiers: primary (Owned) | tracked | secondary (External)."
|
|
3668
|
+
),
|
|
3669
|
+
{ tag: false }
|
|
3670
|
+
).option("--tier <tier>", "Filter by tier: primary | tracked | secondary").option("--domain-contains <text>", "Substring filter on the domain").option("--sort <field>", "Sort by: citations | coverage (default: citations)"),
|
|
3671
|
+
50
|
|
3672
|
+
).action(
|
|
3673
|
+
async (cmdOpts) => {
|
|
3674
|
+
const opts = program2.opts();
|
|
3675
|
+
const format = opts.output || "plain";
|
|
3676
|
+
try {
|
|
3677
|
+
const data = await apiRequest({
|
|
3678
|
+
path: "/org/analytics/citations/domains",
|
|
3679
|
+
params: {
|
|
3680
|
+
...windowParams(cmdOpts),
|
|
3681
|
+
tier: cmdOpts.tier,
|
|
3682
|
+
domain_contains: cmdOpts.domainContains,
|
|
3683
|
+
sort: cmdOpts.sort,
|
|
3684
|
+
limit: cmdOpts.limit,
|
|
3685
|
+
offset: cmdOpts.offset
|
|
3686
|
+
},
|
|
3687
|
+
apiKey: opts.apiKey,
|
|
3688
|
+
baseUrl: opts.baseUrl
|
|
3689
|
+
});
|
|
3690
|
+
const domains = data.domains ?? [];
|
|
3691
|
+
const context = [
|
|
3692
|
+
"",
|
|
3693
|
+
` ${pc10.bold("Cited domains")} ${pc10.dim(`${domains.length} of ${count(data.total)} (offset ${count(data.offset)})`)}`,
|
|
3694
|
+
windowLine(data.window),
|
|
3695
|
+
qualityLine(data.data_quality),
|
|
3696
|
+
` ${pc10.dim(`D = ${count(data.denominators?.cited_run_count)} cited answers, S = ${count(data.denominators?.cited_total)} citation instances`)}`
|
|
3697
|
+
];
|
|
3698
|
+
emitContext(format, context);
|
|
3699
|
+
output(format, {
|
|
3700
|
+
json: data,
|
|
3701
|
+
table: {
|
|
3702
|
+
rows: domains.map((d) => ({
|
|
3703
|
+
rank: d.rank_by_citations,
|
|
3704
|
+
domain: d.domain,
|
|
3705
|
+
tier: d.tier_label || d.tier,
|
|
3706
|
+
answers: count(d.cited_run_count),
|
|
3707
|
+
citations: count(d.cited_total),
|
|
3708
|
+
coverage: rate(d.citation_coverage),
|
|
3709
|
+
share: rate(d.citation_share),
|
|
3710
|
+
avg_pos: position(d.avg_citation_rank)
|
|
3711
|
+
})),
|
|
3712
|
+
columns: [
|
|
3713
|
+
"rank",
|
|
3714
|
+
"domain",
|
|
3715
|
+
"tier",
|
|
3716
|
+
"answers",
|
|
3717
|
+
"citations",
|
|
3718
|
+
"coverage",
|
|
3719
|
+
"share",
|
|
3720
|
+
"avg_pos"
|
|
3721
|
+
]
|
|
3722
|
+
},
|
|
3723
|
+
plain: [
|
|
3724
|
+
...context,
|
|
3725
|
+
"",
|
|
3726
|
+
...domains.length ? domains.map(
|
|
3727
|
+
(d) => ` ${pc10.dim(`#${d.rank_by_citations}`)} ${pc10.bold(d.domain)} ${pc10.dim(`[${d.tier_label || d.tier}]`)}
|
|
3728
|
+
coverage ${rate(d.citation_coverage)} \xB7 share ${rate(d.citation_share)} \xB7 ${count(d.cited_run_count)} cited answers \xB7 ${count(d.cited_total)} citations \xB7 avg position ${position(d.avg_citation_rank)}`
|
|
3729
|
+
) : [" No cited domains in this window."]
|
|
3730
|
+
]
|
|
3731
|
+
});
|
|
3732
|
+
emitNotes(format, data.notes);
|
|
3733
|
+
} catch (err) {
|
|
3734
|
+
error(formatApiError(err));
|
|
3735
|
+
process.exit(1);
|
|
3736
|
+
}
|
|
3737
|
+
}
|
|
3738
|
+
);
|
|
3739
|
+
addPagingOptions(
|
|
3740
|
+
addWindowOptions(
|
|
3741
|
+
analytics.command("pages").description(
|
|
3742
|
+
"URL-grain citation table plus the prompts driving each page's citations. Same Coverage (\xF7D) and Share (\xF7S) denominators as 'analytics domains'."
|
|
3743
|
+
),
|
|
3744
|
+
{ tag: false }
|
|
3745
|
+
).option("--tier <tier>", "Filter by tier: primary | tracked | secondary").option("--domain <domain>", "Restrict to one exact domain").option("--domain-contains <text>", "Substring filter on the domain").option("--url-contains <text>", "Substring filter on the URL").option("--sort <field>", "Sort by: citations | coverage (default: citations)"),
|
|
3746
|
+
50
|
|
3747
|
+
).action(
|
|
3748
|
+
async (cmdOpts) => {
|
|
3749
|
+
const opts = program2.opts();
|
|
3750
|
+
const format = opts.output || "plain";
|
|
3751
|
+
try {
|
|
3752
|
+
const data = await apiRequest({
|
|
3753
|
+
path: "/org/analytics/citations/pages",
|
|
3754
|
+
params: {
|
|
3755
|
+
...windowParams(cmdOpts),
|
|
3756
|
+
tier: cmdOpts.tier,
|
|
3757
|
+
domain: cmdOpts.domain,
|
|
3758
|
+
domain_contains: cmdOpts.domainContains,
|
|
3759
|
+
url_contains: cmdOpts.urlContains,
|
|
3760
|
+
sort: cmdOpts.sort,
|
|
3761
|
+
limit: cmdOpts.limit,
|
|
3762
|
+
offset: cmdOpts.offset
|
|
3763
|
+
},
|
|
3764
|
+
apiKey: opts.apiKey,
|
|
3765
|
+
baseUrl: opts.baseUrl
|
|
3766
|
+
});
|
|
3767
|
+
const pages = data.pages ?? [];
|
|
3768
|
+
const context = [
|
|
3769
|
+
"",
|
|
3770
|
+
` ${pc10.bold("Cited pages")} ${pc10.dim(`${pages.length} of ${count(data.total)} (offset ${count(data.offset)})`)}`,
|
|
3771
|
+
windowLine(data.window),
|
|
3772
|
+
qualityLine(data.data_quality),
|
|
3773
|
+
` ${pc10.dim(`D = ${count(data.denominators?.cited_run_count)} cited answers, S = ${count(data.denominators?.cited_total)} citation instances`)}`
|
|
3774
|
+
];
|
|
3775
|
+
emitContext(format, context);
|
|
3776
|
+
output(format, {
|
|
3777
|
+
json: data,
|
|
3778
|
+
table: {
|
|
3779
|
+
rows: pages.map((p4) => ({
|
|
3780
|
+
url: truncate(p4.url, 70),
|
|
3781
|
+
tier: p4.tier_label || p4.tier,
|
|
3782
|
+
answers: count(p4.cited_run_count),
|
|
3783
|
+
citations: count(p4.cited_total),
|
|
3784
|
+
coverage: rate(p4.citation_coverage),
|
|
3785
|
+
share: rate(p4.citation_share),
|
|
3786
|
+
avg_pos: position(p4.avg_citation_rank)
|
|
3787
|
+
})),
|
|
3788
|
+
columns: [
|
|
3789
|
+
"url",
|
|
3790
|
+
"tier",
|
|
3791
|
+
"answers",
|
|
3792
|
+
"citations",
|
|
3793
|
+
"coverage",
|
|
3794
|
+
"share",
|
|
3795
|
+
"avg_pos"
|
|
3796
|
+
]
|
|
3797
|
+
},
|
|
3798
|
+
plain: [
|
|
3799
|
+
...context,
|
|
3800
|
+
"",
|
|
3801
|
+
...pages.length ? pages.map(
|
|
3802
|
+
(p4) => [
|
|
3803
|
+
` ${pc10.bold(p4.url)} ${pc10.dim(`[${p4.tier_label || p4.tier}]`)}`,
|
|
3804
|
+
` coverage ${rate(p4.citation_coverage)} \xB7 share ${rate(p4.citation_share)} \xB7 ${count(p4.cited_run_count)} cited answers \xB7 ${count(p4.cited_total)} citations`,
|
|
3805
|
+
...(p4.top_prompts ?? []).map(
|
|
3806
|
+
(tp) => ` ${pc10.dim(`\u21B3 ${truncate(tp.prompt_text, 80)} (${count(tp.cited_run_count)} cited answers)`)}`
|
|
3807
|
+
)
|
|
3808
|
+
].join("\n")
|
|
3809
|
+
) : [" No cited pages in this window."]
|
|
3810
|
+
]
|
|
3811
|
+
});
|
|
3812
|
+
emitNotes(format, data.notes);
|
|
3813
|
+
} catch (err) {
|
|
3814
|
+
error(formatApiError(err));
|
|
3815
|
+
process.exit(1);
|
|
3816
|
+
}
|
|
3817
|
+
}
|
|
3818
|
+
);
|
|
3819
|
+
addPagingOptions(
|
|
3820
|
+
addWindowOptions(
|
|
3821
|
+
analytics.command("prompts").description(
|
|
3822
|
+
"Per-prompt performance over the window \u2014 sort ascending by mention_rate to find the prompts where you are invisible. Drill into one with 'senso analytics prompt <promptId>'."
|
|
3823
|
+
)
|
|
3824
|
+
).option("--search <query>", "Filter prompts by question text").option(
|
|
3825
|
+
"--sort <field>",
|
|
3826
|
+
"Sort by: mention_rate | share_of_voice | citations | answered | text (default: mention_rate)"
|
|
3827
|
+
).option("--order <dir>", "Sort direction: asc | desc (default: desc)"),
|
|
3828
|
+
50
|
|
3829
|
+
).action(
|
|
3830
|
+
async (cmdOpts) => {
|
|
3831
|
+
const opts = program2.opts();
|
|
3832
|
+
const format = opts.output || "plain";
|
|
3833
|
+
try {
|
|
3834
|
+
const data = await apiRequest({
|
|
3835
|
+
path: "/org/analytics/prompts",
|
|
3836
|
+
params: {
|
|
3837
|
+
...windowParams(cmdOpts),
|
|
3838
|
+
search: cmdOpts.search,
|
|
3839
|
+
sort: cmdOpts.sort,
|
|
3840
|
+
order: cmdOpts.order,
|
|
3841
|
+
limit: cmdOpts.limit,
|
|
3842
|
+
offset: cmdOpts.offset
|
|
3843
|
+
},
|
|
3844
|
+
apiKey: opts.apiKey,
|
|
3845
|
+
baseUrl: opts.baseUrl
|
|
3846
|
+
});
|
|
3847
|
+
const prompts = data.prompts ?? [];
|
|
3848
|
+
const context = [
|
|
3849
|
+
"",
|
|
3850
|
+
` ${pc10.bold("Prompt performance")} ${pc10.dim(`${prompts.length} of ${count(data.total)} (offset ${count(data.offset)})`)}`,
|
|
3851
|
+
windowLine(data.window),
|
|
3852
|
+
qualityLine(data.data_quality),
|
|
3853
|
+
` ${pc10.dim(`Org-wide over the same window \u2014 mention rate ${rate(data.metrics.mention_rate)}, share of voice ${rate(data.metrics.share_of_voice)}`)}`
|
|
3854
|
+
];
|
|
3855
|
+
emitContext(format, context);
|
|
3856
|
+
output(format, {
|
|
3857
|
+
json: data,
|
|
3858
|
+
table: {
|
|
3859
|
+
rows: prompts.map((p4) => ({
|
|
3860
|
+
prompt_id: p4.prompt_id,
|
|
3861
|
+
prompt: truncate(p4.prompt_text, 40),
|
|
3862
|
+
answered: count(p4.answered_count),
|
|
3863
|
+
mention_rate: rate(p4.mention_rate),
|
|
3864
|
+
sov: rate(p4.share_of_voice),
|
|
3865
|
+
latest_sov: rate(p4.latest?.share_of_voice ?? null),
|
|
3866
|
+
avg_rank: rate(p4.avg_rank),
|
|
3867
|
+
owned_cite_rate: rate(p4.primary_citation_rate)
|
|
3868
|
+
})),
|
|
3869
|
+
columns: [
|
|
3870
|
+
"prompt_id",
|
|
3871
|
+
"prompt",
|
|
3872
|
+
"answered",
|
|
3873
|
+
"mention_rate",
|
|
3874
|
+
"sov",
|
|
3875
|
+
"latest_sov",
|
|
3876
|
+
"avg_rank",
|
|
3877
|
+
"owned_cite_rate"
|
|
3878
|
+
]
|
|
3879
|
+
},
|
|
3880
|
+
plain: [
|
|
3881
|
+
...context,
|
|
3882
|
+
"",
|
|
3883
|
+
...prompts.length ? prompts.map(
|
|
3884
|
+
(p4) => [
|
|
3885
|
+
` ${pc10.bold(truncate(p4.prompt_text, 100))} ${pc10.dim(`[${p4.prompt_type}]`)}`,
|
|
3886
|
+
` mention rate ${rate(p4.mention_rate)} (${count(p4.mentioned_count)}/${count(p4.answered_count)}) \xB7 SoV ${rate(p4.share_of_voice)} (window) \xB7 ${rate(p4.latest?.share_of_voice ?? null)} (latest) \xB7 avg rank ${rate(p4.avg_rank)} \xB7 owned citation rate ${rate(p4.primary_citation_rate)}`,
|
|
3887
|
+
` ${pc10.dim(`ID: ${p4.prompt_id}${p4.tags?.length ? ` \xB7 tags: ${p4.tags.join(", ")}` : ""}`)}`
|
|
3888
|
+
].join("\n")
|
|
3889
|
+
) : [" No prompts matched this filter."]
|
|
3890
|
+
]
|
|
3891
|
+
});
|
|
3892
|
+
emitNotes(format, data.notes);
|
|
3893
|
+
} catch (err) {
|
|
3894
|
+
error(formatApiError(err));
|
|
3895
|
+
process.exit(1);
|
|
3896
|
+
}
|
|
3897
|
+
}
|
|
3898
|
+
);
|
|
3899
|
+
analytics.command("prompt <promptId>").description(
|
|
3900
|
+
"One prompt end to end: its metric history over the window plus the latest full answer from every model \xD7 location."
|
|
3901
|
+
).option("--from <date>", "Window start, YYYY-MM-DD").option("--to <date>", "Window end, YYYY-MM-DD").option("--models <list>", "Comma-separated model filter").option("--location <list>", "Comma-separated location filter, case-sensitive").option("--no-include-answers", "Omit the latest answer bodies (included by default)").action(
|
|
3902
|
+
async (promptId, cmdOpts) => {
|
|
3903
|
+
const opts = program2.opts();
|
|
3904
|
+
const format = opts.output || "plain";
|
|
3905
|
+
try {
|
|
3906
|
+
const data = await apiRequest({
|
|
3907
|
+
path: `/org/analytics/prompts/${promptId}`,
|
|
3908
|
+
params: {
|
|
3909
|
+
from: cmdOpts.from,
|
|
3910
|
+
to: cmdOpts.to,
|
|
3911
|
+
models: cmdOpts.models,
|
|
3912
|
+
location: cmdOpts.location,
|
|
3913
|
+
include_answers: cmdOpts.includeAnswers === false ? "false" : void 0
|
|
3914
|
+
},
|
|
3915
|
+
apiKey: opts.apiKey,
|
|
3916
|
+
baseUrl: opts.baseUrl
|
|
3917
|
+
});
|
|
3918
|
+
const series = data.series ?? [];
|
|
3919
|
+
const answers = data.latest_answers ?? [];
|
|
3920
|
+
const rows = metricRows(data.totals, data.metrics);
|
|
3921
|
+
const context = [
|
|
3922
|
+
"",
|
|
3923
|
+
` ${pc10.bold(data.prompt_text)} ${pc10.dim(`[${data.prompt_type}]`)}`,
|
|
3924
|
+
` ${pc10.dim(`ID: ${data.prompt_id}${data.tags?.length ? ` \xB7 tags: ${data.tags.join(", ")}` : ""}`)}`,
|
|
3925
|
+
windowLine(data.window),
|
|
3926
|
+
qualityLine(data.data_quality)
|
|
3927
|
+
];
|
|
3928
|
+
emitContext(format, context);
|
|
3929
|
+
output(format, {
|
|
3930
|
+
json: data,
|
|
3931
|
+
table: {
|
|
3932
|
+
rows: series.map((p4) => ({
|
|
3933
|
+
period: p4.period_start,
|
|
3934
|
+
answered: count(p4.answered_count),
|
|
3935
|
+
mentioned: count(p4.mentioned_count),
|
|
3936
|
+
mention_rate: rate(p4.mention_rate),
|
|
3937
|
+
sov: rate(p4.share_of_voice),
|
|
3938
|
+
avg_rank: rate(p4.avg_rank)
|
|
3939
|
+
})),
|
|
3940
|
+
columns: [
|
|
3941
|
+
"period",
|
|
3942
|
+
"answered",
|
|
3943
|
+
"mentioned",
|
|
3944
|
+
"mention_rate",
|
|
3945
|
+
"sov",
|
|
3946
|
+
"avg_rank"
|
|
3947
|
+
]
|
|
3948
|
+
},
|
|
3949
|
+
plain: [
|
|
3950
|
+
...context,
|
|
3951
|
+
"",
|
|
3952
|
+
...metricPlainLines(rows),
|
|
3953
|
+
"",
|
|
3954
|
+
` ${pc10.bold("Series")}`,
|
|
3955
|
+
...series.length ? series.map(
|
|
3956
|
+
(p4) => ` ${p4.period_start} answered ${count(p4.answered_count)} mentioned ${count(p4.mentioned_count)} rate ${rate(p4.mention_rate)} SoV ${rate(p4.share_of_voice)} rank ${rate(p4.avg_rank)}`
|
|
3957
|
+
) : [" No rollup days in this window."],
|
|
3958
|
+
...answers.length ? [
|
|
3959
|
+
"",
|
|
3960
|
+
` ${pc10.bold("Latest answers")}`,
|
|
3961
|
+
...answers.map(
|
|
3962
|
+
(a) => [
|
|
3963
|
+
` ${pc10.bold(`${a.model} \xB7 ${a.location}`)} ${pc10.dim(a.run_at)}`,
|
|
3964
|
+
` mentioned ${a.mentioned ? "yes" : "no"} \xB7 rank ${a.rank === null || a.rank === void 0 ? NO_VALUE : `#${a.rank}`} \xB7 sentiment ${a.sentiment ?? NO_VALUE} \xB7 ${count(a.citations?.length ?? 0)} citations`,
|
|
3965
|
+
` ${pc10.dim(truncate(a.response_text, 200))}`
|
|
3966
|
+
].join("\n")
|
|
3967
|
+
)
|
|
3968
|
+
] : []
|
|
3969
|
+
]
|
|
3970
|
+
});
|
|
3971
|
+
emitNotes(format, data.notes);
|
|
3972
|
+
} catch (err) {
|
|
3973
|
+
error(formatApiError(err));
|
|
3974
|
+
process.exit(1);
|
|
3975
|
+
}
|
|
3976
|
+
}
|
|
3977
|
+
);
|
|
3978
|
+
addPagingOptions(
|
|
3979
|
+
analytics.command("answers").description(
|
|
3980
|
+
"The newest stored answer per prompt \xD7 model \xD7 location, with its citations and competitor mentions. This is a snapshot, not a window: --from/--to filter on when each answer was collected, so narrowing them hides combinations instead of returning older answers. Historical answer text is not retained."
|
|
3981
|
+
).option("--from <date>", "Answers collected on or after this date, YYYY-MM-DD (hides rows, never reveals older answers)").option("--to <date>", "Answers collected on or before this date, YYYY-MM-DD (hides rows, never reveals older answers)").option("--models <list>", "Comma-separated model filter").option("--location <list>", "Comma-separated location filter, case-sensitive").option("--prompt-type <type>", "Funnel stage: awareness | consideration | evaluation | decision").option("--tag <tag>", "Restrict to prompts carrying this tag").option("--mentioned <bool>", "Only answers that did (true) or did not (false) name your brand").option("--cited <bool>", "Only answers that did (true) or did not (false) cite anything").option("--citation-tier <tier>", "Only answers citing this tier: primary | tracked | secondary"),
|
|
3982
|
+
25
|
|
3983
|
+
).action(
|
|
3984
|
+
async (cmdOpts) => {
|
|
3985
|
+
const opts = program2.opts();
|
|
3986
|
+
const format = opts.output || "plain";
|
|
3987
|
+
const mentioned = normalizeBool("mentioned", cmdOpts.mentioned);
|
|
3988
|
+
const cited = normalizeBool("cited", cmdOpts.cited);
|
|
3989
|
+
try {
|
|
3990
|
+
const data = await apiRequest({
|
|
3991
|
+
path: "/org/analytics/answers/latest",
|
|
3992
|
+
params: {
|
|
3993
|
+
from: cmdOpts.from,
|
|
3994
|
+
to: cmdOpts.to,
|
|
3995
|
+
models: cmdOpts.models,
|
|
3996
|
+
location: cmdOpts.location,
|
|
3997
|
+
prompt_type: cmdOpts.promptType,
|
|
3998
|
+
tag: cmdOpts.tag,
|
|
3999
|
+
mentioned,
|
|
4000
|
+
cited,
|
|
4001
|
+
citation_tier: cmdOpts.citationTier,
|
|
4002
|
+
limit: cmdOpts.limit,
|
|
4003
|
+
offset: cmdOpts.offset
|
|
4004
|
+
},
|
|
4005
|
+
apiKey: opts.apiKey,
|
|
4006
|
+
baseUrl: opts.baseUrl
|
|
4007
|
+
});
|
|
4008
|
+
const answers = data.answers ?? [];
|
|
4009
|
+
const collectedLine = cmdOpts.from || cmdOpts.to ? ` ${pc10.dim(`Collected ${cmdOpts.from ?? "any"} \u2192 ${cmdOpts.to ?? "any"} \u2014 combinations whose newest answer falls outside this window are hidden, not replaced by older answers.`)}` : "";
|
|
4010
|
+
const context = [
|
|
4011
|
+
"",
|
|
4012
|
+
` ${pc10.bold("Latest answers")} ${pc10.dim(`${answers.length} of ${count(data.total)} (offset ${count(data.offset)})`)}`,
|
|
4013
|
+
` ${pc10.dim("Snapshot of the newest answer per prompt \xD7 model \xD7 location \u2014 not a sample of any window.")}`,
|
|
4014
|
+
...collectedLine ? [collectedLine] : []
|
|
4015
|
+
];
|
|
4016
|
+
emitContext(format, context);
|
|
4017
|
+
output(format, {
|
|
4018
|
+
json: data,
|
|
4019
|
+
table: {
|
|
4020
|
+
rows: answers.map((a) => ({
|
|
4021
|
+
run_at: (a.run_at || "").slice(0, 10),
|
|
4022
|
+
model: a.model,
|
|
4023
|
+
location: a.location,
|
|
4024
|
+
prompt: truncate(a.prompt_text, 40),
|
|
4025
|
+
mentioned: a.mentioned ? "yes" : "no",
|
|
4026
|
+
rank: a.rank === null || a.rank === void 0 ? NO_VALUE : `#${a.rank}`,
|
|
4027
|
+
sentiment: a.sentiment ?? NO_VALUE,
|
|
4028
|
+
citations: count(a.citations?.length ?? 0)
|
|
4029
|
+
})),
|
|
4030
|
+
columns: [
|
|
4031
|
+
"run_at",
|
|
4032
|
+
"model",
|
|
4033
|
+
"location",
|
|
4034
|
+
"prompt",
|
|
4035
|
+
"mentioned",
|
|
4036
|
+
"rank",
|
|
4037
|
+
"sentiment",
|
|
4038
|
+
"citations"
|
|
4039
|
+
]
|
|
4040
|
+
},
|
|
4041
|
+
plain: [
|
|
4042
|
+
...context,
|
|
4043
|
+
"",
|
|
4044
|
+
...answers.length ? answers.map(
|
|
4045
|
+
(a) => [
|
|
4046
|
+
` ${pc10.bold(truncate(a.prompt_text, 100))} ${pc10.dim(`[${a.prompt_type}]`)}`,
|
|
4047
|
+
` ${a.model} \xB7 ${a.location} \xB7 ${pc10.dim(a.run_at)}`,
|
|
4048
|
+
` mentioned ${a.mentioned ? "yes" : "no"} \xB7 rank ${a.rank === null || a.rank === void 0 ? NO_VALUE : `#${a.rank}`} \xB7 sentiment ${a.sentiment ?? NO_VALUE} \xB7 ${count(a.citations?.length ?? 0)} citations`,
|
|
4049
|
+
` ${pc10.dim(truncate(a.response_text, 200))}`,
|
|
4050
|
+
` ${pc10.dim(`ID: ${a.prompt_id}`)}`
|
|
4051
|
+
].join("\n")
|
|
4052
|
+
) : [" No answers matched this filter."]
|
|
4053
|
+
]
|
|
4054
|
+
});
|
|
4055
|
+
emitNotes(format, data.notes);
|
|
4056
|
+
} catch (err) {
|
|
4057
|
+
error(formatApiError(err));
|
|
4058
|
+
process.exit(1);
|
|
4059
|
+
}
|
|
4060
|
+
}
|
|
4061
|
+
);
|
|
4062
|
+
analytics.command("glossary").description(
|
|
4063
|
+
"Canonical definition, denominator and gotcha for every metric these endpoints emit. Read this before quoting a number \u2014 a Citation Rate divides by cited answers (D), a Citation Share divides by citation instances (S), and they are not interchangeable."
|
|
4064
|
+
).action(async () => {
|
|
4065
|
+
const opts = program2.opts();
|
|
4066
|
+
const format = opts.output || "plain";
|
|
4067
|
+
try {
|
|
4068
|
+
const data = await apiRequest({
|
|
4069
|
+
path: "/org/analytics/glossary",
|
|
4070
|
+
apiKey: opts.apiKey,
|
|
4071
|
+
baseUrl: opts.baseUrl
|
|
4072
|
+
});
|
|
4073
|
+
const entries = data.entries ?? [];
|
|
4074
|
+
emitContext(format, [
|
|
4075
|
+
"",
|
|
4076
|
+
` ${pc10.bold("Metric glossary")} ${pc10.dim(`${entries.length} metrics \u2014 gotchas shown in plain and json output`)}`
|
|
4077
|
+
]);
|
|
4078
|
+
output(format, {
|
|
4079
|
+
json: data,
|
|
4080
|
+
table: {
|
|
4081
|
+
rows: entries.map((e) => ({
|
|
4082
|
+
metric: e.metric,
|
|
4083
|
+
denominator: e.denominator || NO_VALUE,
|
|
4084
|
+
definition: e.definition
|
|
4085
|
+
})),
|
|
4086
|
+
columns: ["metric", "denominator", "definition"]
|
|
4087
|
+
},
|
|
4088
|
+
plain: [
|
|
4089
|
+
"",
|
|
4090
|
+
` ${pc10.bold("Metric glossary")}`,
|
|
4091
|
+
"",
|
|
4092
|
+
...entries.map(
|
|
4093
|
+
(e) => [
|
|
4094
|
+
` ${pc10.bold(e.metric)}`,
|
|
4095
|
+
` ${e.definition}`,
|
|
4096
|
+
...e.denominator ? [` ${pc10.dim(`Denominator: ${e.denominator}`)}`] : [],
|
|
4097
|
+
...e.gotcha ? [` ${pc10.yellow("Gotcha:")} ${e.gotcha}`] : []
|
|
4098
|
+
].join("\n")
|
|
4099
|
+
)
|
|
4100
|
+
]
|
|
4101
|
+
});
|
|
4102
|
+
} catch (err) {
|
|
4103
|
+
error(formatApiError(err));
|
|
4104
|
+
process.exit(1);
|
|
4105
|
+
}
|
|
4106
|
+
});
|
|
4107
|
+
analytics.command("filters").description(
|
|
4108
|
+
"The models, locations, prompt types, tags and tracked competitors that actually have data for this org, plus the span of rollup days available \u2014 so you never guess a model spelling or query an empty window."
|
|
4109
|
+
).action(async () => {
|
|
4110
|
+
const opts = program2.opts();
|
|
4111
|
+
const format = opts.output || "plain";
|
|
4112
|
+
try {
|
|
4113
|
+
const data = await apiRequest({
|
|
4114
|
+
path: "/org/analytics/filters",
|
|
4115
|
+
apiKey: opts.apiKey,
|
|
4116
|
+
baseUrl: opts.baseUrl
|
|
4117
|
+
});
|
|
4118
|
+
const models = (data.models ?? []).map((m) => m.display_name || m.id);
|
|
4119
|
+
const competitors = (data.tracked_competitors ?? []).map(
|
|
4120
|
+
(c) => c.display_name || c.id
|
|
4121
|
+
);
|
|
4122
|
+
const range = data.date_range;
|
|
4123
|
+
const rangeText = range?.earliest_day && range?.latest_day ? `${range.earliest_day} \u2192 ${range.latest_day}` : "no rollup days yet";
|
|
4124
|
+
const list = (values) => values.length ? values.join(", ") : NO_VALUE;
|
|
4125
|
+
emitContext(format, ["", ` ${pc10.bold("Available filters")}`]);
|
|
4126
|
+
output(format, {
|
|
4127
|
+
json: data,
|
|
4128
|
+
table: {
|
|
4129
|
+
rows: [
|
|
4130
|
+
{ filter: "--models", values: list(models) },
|
|
4131
|
+
{ filter: "--location", values: list(data.locations ?? []) },
|
|
4132
|
+
{ filter: "--prompt-type", values: list(data.prompt_types ?? []) },
|
|
4133
|
+
{ filter: "--tag", values: list(data.tags ?? []) },
|
|
4134
|
+
{ filter: "tracked competitors", values: list(competitors) },
|
|
4135
|
+
{ filter: "date range", values: rangeText }
|
|
4136
|
+
],
|
|
4137
|
+
columns: ["filter", "values"]
|
|
4138
|
+
},
|
|
4139
|
+
plain: [
|
|
4140
|
+
"",
|
|
4141
|
+
` ${pc10.bold("Available filters")}`,
|
|
4142
|
+
"",
|
|
4143
|
+
` ${pc10.bold("--models")} ${list(models)}`,
|
|
4144
|
+
` ${pc10.bold("--location")} ${list(data.locations ?? [])}`,
|
|
4145
|
+
` ${pc10.bold("--prompt-type")} ${list(data.prompt_types ?? [])}`,
|
|
4146
|
+
` ${pc10.bold("--tag")} ${list(data.tags ?? [])}`,
|
|
4147
|
+
"",
|
|
4148
|
+
` ${pc10.bold("Tracked competitors")} ${list(competitors)}`,
|
|
4149
|
+
` ${pc10.bold("Date range")} ${rangeText}`
|
|
4150
|
+
]
|
|
4151
|
+
});
|
|
4152
|
+
emitNotes(format, data.notes);
|
|
4153
|
+
} catch (err) {
|
|
4154
|
+
error(formatApiError(err));
|
|
4155
|
+
process.exit(1);
|
|
4156
|
+
}
|
|
4157
|
+
});
|
|
4158
|
+
}
|
|
4159
|
+
|
|
4160
|
+
// src/commands/industries.ts
|
|
4161
|
+
function handlePartnerError(err) {
|
|
4162
|
+
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
|
|
4163
|
+
error(
|
|
4164
|
+
"This request was rejected by the Senso API's partner authentication."
|
|
4165
|
+
);
|
|
4166
|
+
info(
|
|
4167
|
+
"`senso industries` reads partner-scoped endpoints (/partner/*) and needs a PARTNER API key. The organization key stored by `senso login` cannot access them \u2014 logging in again will not help."
|
|
4168
|
+
);
|
|
4169
|
+
info(
|
|
4170
|
+
"If you have a partner key, pass it per-command with `--api-key <partner-key>` or export SENSO_API_KEY."
|
|
4171
|
+
);
|
|
4172
|
+
info(
|
|
4173
|
+
"For metrics about your own organization, use `senso analytics` \u2014 e.g. `senso analytics summary`, `senso analytics domains`, `senso analytics glossary`."
|
|
4174
|
+
);
|
|
4175
|
+
process.exit(1);
|
|
4176
|
+
}
|
|
4177
|
+
error(formatApiError(err));
|
|
4178
|
+
process.exit(1);
|
|
4179
|
+
}
|
|
4180
|
+
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
4181
|
+
function isUuid(value) {
|
|
4182
|
+
return UUID_RE.test(value.trim());
|
|
4183
|
+
}
|
|
4184
|
+
function ciParams(cmdOpts) {
|
|
4185
|
+
return {
|
|
4186
|
+
from: cmdOpts.from,
|
|
4187
|
+
to: cmdOpts.to,
|
|
4188
|
+
location: cmdOpts.location,
|
|
4189
|
+
models: cmdOpts.models
|
|
4190
|
+
};
|
|
4191
|
+
}
|
|
4192
|
+
async function resolveIndustryId(industry, opts) {
|
|
4193
|
+
if (isUuid(industry)) return industry.trim();
|
|
4194
|
+
const data = await apiRequest({
|
|
4195
|
+
path: "/partner/industries",
|
|
4196
|
+
params: { search: industry },
|
|
4197
|
+
apiKey: opts.apiKey,
|
|
4198
|
+
baseUrl: opts.baseUrl
|
|
4199
|
+
});
|
|
4200
|
+
const match = data.industries?.[0];
|
|
4201
|
+
if (!match?.industry_id) {
|
|
4202
|
+
throw new Error(`No industry found matching "${industry}".`);
|
|
4203
|
+
}
|
|
4204
|
+
return match.industry_id;
|
|
4205
|
+
}
|
|
4206
|
+
function registerIndustriesCommands(program2) {
|
|
4207
|
+
const industries = program2.command("industries").description('Explore industry-level competitive intelligence across a partner network \u2014 brand share-of-voice, domain citations, and per-prompt metrics. The <industry> argument accepts either a UUID or a name (e.g. "Automotive"). REQUIRES A PARTNER API KEY: these commands read /partner/* endpoints, which reject the organization key stored by `senso login`. For metrics about your own organization, use `senso analytics`.');
|
|
4208
|
+
industries.command("list").description("List industries visible to the partner. Use --search to filter by name.").option("--search <q>", "Filter industries by name").action(async (cmdOpts) => {
|
|
4209
|
+
const opts = program2.opts();
|
|
4210
|
+
try {
|
|
4211
|
+
const data = await apiRequest({
|
|
4212
|
+
path: "/partner/industries",
|
|
4213
|
+
params: { search: cmdOpts.search },
|
|
4214
|
+
apiKey: opts.apiKey,
|
|
4215
|
+
baseUrl: opts.baseUrl
|
|
4216
|
+
});
|
|
4217
|
+
console.log(JSON.stringify(data, null, 2));
|
|
4218
|
+
} catch (err) {
|
|
4219
|
+
handlePartnerError(err);
|
|
4220
|
+
}
|
|
4221
|
+
});
|
|
4222
|
+
industries.command("summary <industry>").description("One-call, slide-ready overview of an industry: brand counts, share-of-voice, and citation totals over a time window.").option("--from <date>", "Start date (YYYY-MM-DD)").option("--to <date>", "End date (YYYY-MM-DD)").option("--location <code>", "2-letter location code (e.g. US)").option("--models <list>", "Comma-separated model filter").action(async (industry, cmdOpts) => {
|
|
4223
|
+
const opts = program2.opts();
|
|
4224
|
+
try {
|
|
4225
|
+
const industryId = await resolveIndustryId(industry, opts);
|
|
4226
|
+
const data = await apiRequest({
|
|
4227
|
+
path: `/partner/industries/${industryId}/summary`,
|
|
4228
|
+
params: ciParams(cmdOpts),
|
|
4229
|
+
apiKey: opts.apiKey,
|
|
4230
|
+
baseUrl: opts.baseUrl
|
|
4231
|
+
});
|
|
4232
|
+
console.log(JSON.stringify(data, null, 2));
|
|
4233
|
+
} catch (err) {
|
|
4234
|
+
handlePartnerError(err);
|
|
4235
|
+
}
|
|
4236
|
+
});
|
|
4237
|
+
industries.command("brand <industry> <brandName>").description("Everything about one brand within an industry, merged across surface-form spellings. Returns mentioned=false when the brand is never named.").option("--from <date>", "Start date (YYYY-MM-DD)").option("--to <date>", "End date (YYYY-MM-DD)").option("--location <code>", "2-letter location code (e.g. US)").option("--models <list>", "Comma-separated model filter").action(async (industry, brandName, cmdOpts) => {
|
|
4238
|
+
const opts = program2.opts();
|
|
4239
|
+
try {
|
|
4240
|
+
const industryId = await resolveIndustryId(industry, opts);
|
|
4241
|
+
const data = await apiRequest({
|
|
4242
|
+
path: `/partner/industries/${industryId}/brands/${encodeURIComponent(brandName)}`,
|
|
4243
|
+
params: ciParams(cmdOpts),
|
|
4244
|
+
apiKey: opts.apiKey,
|
|
4245
|
+
baseUrl: opts.baseUrl
|
|
4246
|
+
});
|
|
4247
|
+
console.log(JSON.stringify(data, null, 2));
|
|
4248
|
+
} catch (err) {
|
|
4249
|
+
handlePartnerError(err);
|
|
4250
|
+
}
|
|
4251
|
+
});
|
|
4252
|
+
industries.command("domain <industry> <domainOrUrl>").description("Direct domain/URL citation lookup within an industry. Returns cited=false when the domain is never cited.").option("--from <date>", "Start date (YYYY-MM-DD)").option("--to <date>", "End date (YYYY-MM-DD)").option("--location <code>", "2-letter location code (e.g. US)").option("--models <list>", "Comma-separated model filter").action(async (industry, domainOrUrl, cmdOpts) => {
|
|
4253
|
+
const opts = program2.opts();
|
|
4254
|
+
try {
|
|
4255
|
+
const industryId = await resolveIndustryId(industry, opts);
|
|
4256
|
+
const data = await apiRequest({
|
|
4257
|
+
path: `/partner/industries/${industryId}/domains/${encodeURIComponent(domainOrUrl)}`,
|
|
4258
|
+
params: ciParams(cmdOpts),
|
|
4259
|
+
apiKey: opts.apiKey,
|
|
4260
|
+
baseUrl: opts.baseUrl
|
|
4261
|
+
});
|
|
4262
|
+
console.log(JSON.stringify(data, null, 2));
|
|
4263
|
+
} catch (err) {
|
|
4264
|
+
handlePartnerError(err);
|
|
4265
|
+
}
|
|
4266
|
+
});
|
|
4267
|
+
industries.command("prompt-metrics <industry>").description("Pure-industry per-prompt metrics (no single-org overlay) \u2014 how each tracked prompt performs across the industry.").option("--from <date>", "Start date (YYYY-MM-DD)").option("--to <date>", "End date (YYYY-MM-DD)").option("--location <code>", "2-letter location code (e.g. US)").option("--models <list>", "Comma-separated model filter").option("--limit <n>", "Maximum prompts to return").option("--offset <n>", "Number of prompts to skip (for pagination)").action(async (industry, cmdOpts) => {
|
|
4268
|
+
const opts = program2.opts();
|
|
4269
|
+
try {
|
|
4270
|
+
const industryId = await resolveIndustryId(industry, opts);
|
|
4271
|
+
const data = await apiRequest({
|
|
4272
|
+
path: `/partner/industries/${industryId}/prompt-metrics`,
|
|
4273
|
+
params: { ...ciParams(cmdOpts), limit: cmdOpts.limit, offset: cmdOpts.offset },
|
|
4274
|
+
apiKey: opts.apiKey,
|
|
4275
|
+
baseUrl: opts.baseUrl
|
|
4276
|
+
});
|
|
4277
|
+
console.log(JSON.stringify(data, null, 2));
|
|
4278
|
+
} catch (err) {
|
|
4279
|
+
handlePartnerError(err);
|
|
4280
|
+
}
|
|
4281
|
+
});
|
|
4282
|
+
industries.command("glossary").description("Canonical metric glossary \u2014 the citable definition of every competitive-intelligence metric returned by these endpoints.").action(async () => {
|
|
4283
|
+
const opts = program2.opts();
|
|
4284
|
+
try {
|
|
4285
|
+
const data = await apiRequest({
|
|
4286
|
+
path: "/partner/glossary",
|
|
4287
|
+
apiKey: opts.apiKey,
|
|
4288
|
+
baseUrl: opts.baseUrl
|
|
4289
|
+
});
|
|
4290
|
+
console.log(JSON.stringify(data, null, 2));
|
|
4291
|
+
} catch (err) {
|
|
4292
|
+
handlePartnerError(err);
|
|
4293
|
+
}
|
|
4294
|
+
});
|
|
4295
|
+
}
|
|
4296
|
+
|
|
4297
|
+
// src/commands/update.ts
|
|
4298
|
+
import semver2 from "semver";
|
|
4299
|
+
import pc11 from "picocolors";
|
|
3027
4300
|
import { execSync } from "child_process";
|
|
3028
4301
|
var NPM_PACKAGE2 = "@senso-ai/cli";
|
|
3029
4302
|
function registerUpdateCommand(program2) {
|
|
3030
4303
|
program2.command("update").description("Update CLI to the latest version").action(async () => {
|
|
3031
|
-
info(`Current version: ${
|
|
4304
|
+
info(`Current version: ${pc11.bold(version)}`);
|
|
3032
4305
|
info("Checking npm for updates...");
|
|
3033
4306
|
const latest = await getLatestVersion();
|
|
3034
4307
|
if (!latest) {
|
|
@@ -3039,7 +4312,7 @@ function registerUpdateCommand(program2) {
|
|
|
3039
4312
|
success(`Already on the latest version (${version}).`);
|
|
3040
4313
|
return;
|
|
3041
4314
|
}
|
|
3042
|
-
info(`New version available: ${
|
|
4315
|
+
info(`New version available: ${pc11.bold(latest)}`);
|
|
3043
4316
|
info("Updating...");
|
|
3044
4317
|
try {
|
|
3045
4318
|
execSync(`npm install -g ${NPM_PACKAGE2}@latest`, {
|
|
@@ -3049,7 +4322,7 @@ function registerUpdateCommand(program2) {
|
|
|
3049
4322
|
} catch {
|
|
3050
4323
|
error("Update failed. Please reinstall manually:");
|
|
3051
4324
|
console.log(
|
|
3052
|
-
` ${
|
|
4325
|
+
` ${pc11.cyan(`npm install -g ${NPM_PACKAGE2}`)}`
|
|
3053
4326
|
);
|
|
3054
4327
|
process.exit(1);
|
|
3055
4328
|
}
|
|
@@ -3087,6 +4360,12 @@ registerKBCommands(program);
|
|
|
3087
4360
|
registerPermissionsCommands(program);
|
|
3088
4361
|
registerTagsCommands(program);
|
|
3089
4362
|
registerProductLineCommands(program);
|
|
4363
|
+
registerRolesCommands(program);
|
|
4364
|
+
registerCompetitorsCommands(program);
|
|
4365
|
+
registerTrackedSourcesCommands(program);
|
|
4366
|
+
registerGeneratedContentCommands(program);
|
|
4367
|
+
registerAnalyticsCommands(program);
|
|
4368
|
+
registerIndustriesCommands(program);
|
|
3090
4369
|
registerUpdateCommand(program);
|
|
3091
4370
|
async function main() {
|
|
3092
4371
|
const quiet = process.argv.includes("--quiet") || process.argv.includes("--output") && process.argv[process.argv.indexOf("--output") + 1] === "json";
|