@senso-ai/cli 0.11.1 → 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.
Files changed (3) hide show
  1. package/README.md +65 -19
  2. package/dist/cli.js +946 -6
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -214,6 +214,63 @@ senso prompts delete <promptId> Delete a prompt
214
214
 
215
215
  Options: `prompts list` supports `--limit`, `--offset`, `--search`, `--sort`.
216
216
 
217
+ ### Analytics (GEO)
218
+
219
+ Read-only metrics for your own organization: how often the AI models name your brand, your share of every brand mention the models made, and which domains and pages get cited. Works with the organization API key from `senso login`.
220
+
221
+ ```
222
+ senso analytics summary Headline metrics + previous window + deltas
223
+ senso analytics mentions Visibility time series (day or week buckets)
224
+ senso analytics citations Citation rates and shares, with the series
225
+ senso analytics domains Cited domains, ranked (coverage + share)
226
+ senso analytics pages Cited pages, with the prompts driving them
227
+ senso analytics prompts Per-prompt performance table
228
+ senso analytics prompt <promptId> One prompt: history + latest full answers
229
+ senso analytics answers Latest answer per prompt × model × location
230
+ senso analytics glossary Canonical definition of every metric
231
+ senso analytics filters Filter values that have data for this org
232
+ ```
233
+
234
+ Options:
235
+
236
+ | Command | Options |
237
+ |---------|---------|
238
+ | all except `prompt <promptId>`, `glossary`, `filters` | `--from`, `--to`, `--models`, `--location`, `--prompt-type`, `--tag` |
239
+ | `mentions`, `citations` | `--group-by <day\|week>` |
240
+ | `domains` | `--tier`, `--domain-contains`, `--sort <citations\|coverage>`, `--limit`, `--offset` |
241
+ | `pages` | `--tier`, `--domain`, `--domain-contains`, `--url-contains`, `--sort <citations\|coverage>`, `--limit`, `--offset` |
242
+ | `prompts` | `--search`, `--sort <mention_rate\|share_of_voice\|citations\|answered\|text>`, `--order <asc\|desc>`, `--limit`, `--offset` |
243
+ | `prompt <promptId>` | `--from`, `--to`, `--models`, `--location`, `--no-include-answers` |
244
+ | `answers` | `--from`, `--to`, `--models`, `--location`, `--prompt-type`, `--tag`, `--mentioned <bool>`, `--cited <bool>`, `--citation-tier <primary\|tracked\|secondary>`, `--limit`, `--offset` |
245
+
246
+ `--from`/`--to` are `YYYY-MM-DD` and default to the 30 days ending at the most recent day that has data for your model/location filter (max window: 365 days). `--models` and `--location` are comma-separated; locations are case-sensitive exact codes (`US`, `US/California`) — the API also accepts `locations` as an alias for the `location` query param. Run `senso analytics filters` to see the values that actually have data.
247
+
248
+ `analytics answers` is a snapshot, not a window. It always returns the newest stored answer per prompt × model × location, and `--from`/`--to` filter on `run_at` — when that answer was collected. Narrowing the window therefore **hides** prompt × model × location combinations whose latest answer falls outside it; it does not return older answers in their place. Use `analytics mentions` or `analytics citations` for history.
249
+
250
+ Reading the numbers:
251
+
252
+ - **Share of Voice** is your mention instances ÷ `brand_mention_total` — mentions of *every* brand the models named, not just your tracked competitors. It matches the Share of Voice in the Senso app. `tracked_mention_total` is still returned as a raw count in `totals`, but it is not the denominator.
253
+ - **Citation Rate** and **Citation Coverage** divide by `D` — answers with at least one citation. **Citation Share** divides by `S` — total citation instances. They are different metrics on different denominators; every table shows the numerator and denominator next to the percentage so you can check.
254
+ - The three tier **rates** are independent and can sum past 100% (one answer can cite an owned page and an external page). The three tier **shares** partition and sum to exactly 100%.
255
+ - A metric renders as `—` when its denominator was zero. That is "not measured", not 0%.
256
+ - Every response carries `notes[]` — caveats about window truncation, null denominators and tracking-set dependence. They are printed under a **Notes** heading in `plain` and `table` output, and are part of the payload in `--output json`.
257
+ - `senso analytics glossary` is the canonical definition, denominator and gotcha for every metric.
258
+
259
+ ### Industry Intelligence (partner key required)
260
+
261
+ ```
262
+ senso industries list List industries visible to the partner
263
+ senso industries summary <industry> Industry overview over a time window
264
+ senso industries brand <industry> <brandName> One brand within an industry
265
+ senso industries domain <industry> <domainOrUrl> Domain/URL citation lookup
266
+ senso industries prompt-metrics <industry> Per-prompt industry metrics
267
+ senso industries glossary Competitive-intelligence metric glossary
268
+ ```
269
+
270
+ These commands read partner-scoped endpoints and **require a partner API key**. The organization key stored by `senso login` is rejected with a 401/403 — pass a partner key with `--api-key <key>` or `SENSO_API_KEY`. For metrics about your own organization, use `senso analytics` instead.
271
+
272
+ Options: all except `list` and `glossary` support `--from`, `--to`, `--location`, `--models`; `prompt-metrics` also supports `--limit`, `--offset`; `list` supports `--search`. The `<industry>` argument accepts a UUID or a name (e.g. `"Automotive"`).
273
+
217
274
  ### Organization
218
275
 
219
276
  ```
@@ -248,15 +305,6 @@ senso run-config schedule Get run schedule (days of week)
248
305
  senso run-config set-schedule Set run schedule (--data)
249
306
  ```
250
307
 
251
- ### Notifications
252
-
253
- ```
254
- senso notifications list List notifications
255
- senso notifications read <id> Mark notification as read
256
- ```
257
-
258
- Options: `notifications list` supports `--limit`, `--offset`, `--unread-only`.
259
-
260
308
  ### CLI Management
261
309
 
262
310
  ```
@@ -327,7 +375,7 @@ cnt_def456 Variable Rate Products draft
327
375
 
328
376
  ## Auto-Update
329
377
 
330
- The CLI checks for new versions once every 24 hours (via GitHub releases) and shows a notice on stderr if an update is available:
378
+ The CLI checks for new versions once every 24 hours (via the npm registry) and shows a notice on stderr if an update is available:
331
379
 
332
380
  ```
333
381
  ╭──────────────────────────────────────────────╮
@@ -386,12 +434,13 @@ src/
386
434
  │ ├── brand-kit.ts # get, set
387
435
  │ ├── content-types.ts # CRUD
388
436
  │ ├── prompts.ts # CRUD
437
+ │ ├── analytics.ts # org GEO analytics (summary, citations, prompts, …)
438
+ │ ├── industries.ts # partner-scoped competitive intelligence
389
439
  │ ├── org.ts # get, update
390
440
  │ ├── users.ts # CRUD + set-current
391
441
  │ ├── api-keys.ts # CRUD + revoke
392
442
  │ ├── members.ts # list
393
443
  │ ├── run-config.ts # models, schedule
394
- │ ├── notifications.ts # list, read
395
444
  │ └── update.ts # self-update
396
445
  ├── lib/
397
446
  │ ├── api-client.ts # HTTP wrapper (native fetch, X-API-Key auth)
@@ -401,7 +450,7 @@ src/
401
450
  └── utils/
402
451
  ├── logger.ts # Colored log helpers (picocolors)
403
452
  ├── branding.ts # ASCII logo, gradient banner, boxed panels
404
- └── updater.ts # GitHub releases version check
453
+ └── updater.ts # npm registry version check
405
454
  ```
406
455
 
407
456
  ### Tech Stack
@@ -420,7 +469,7 @@ src/
420
469
 
421
470
  ### Releasing a New Version
422
471
 
423
- This project uses [semantic versioning](https://semver.org/). The CLI's auto-update checker compares the installed version against the latest GitHub release using semver, so pre-release tags and version ordering are handled correctly.
472
+ This project uses [semantic versioning](https://semver.org/). Publishing to npm is automated: pushing a `v*` tag runs `.github/workflows/publish.yml`, which builds the CLI and publishes it via [npm Trusted Publishing](https://docs.npmjs.com/trusted-publishers) (GitHub Actions OIDC, no token secrets). Every pull request and push to `main` runs `.github/workflows/ci.yml` (typecheck, build, tests, smoke test).
424
473
 
425
474
  ```bash
426
475
  # 1. Bump the version in package.json, commit, and create a git tag
@@ -428,14 +477,11 @@ npm version patch # 0.1.0 → 0.1.1 (bug fixes)
428
477
  npm version minor # 0.1.0 → 0.2.0 (new features, backwards-compatible)
429
478
  npm version major # 0.1.0 → 1.0.0 (breaking changes)
430
479
 
431
- # 2. Push the commit and tag
432
- git push origin main --tags
433
-
434
- # 3. Create a GitHub release (this is what the auto-updater checks)
435
- gh release create v0.2.0 --title "v0.2.0" --notes "Release notes here"
480
+ # 2. Push the commit and tag — the tag triggers the publish workflow
481
+ git push --follow-tags
436
482
  ```
437
483
 
438
- After step 3, users running the CLI will see the update notice within 24 hours (or immediately via `senso update`).
484
+ The publish workflow refuses to run if the tag does not match the version in `package.json`. Once it finishes, users running the CLI will see the update notice within 24 hours (or immediately via `senso update`).
439
485
 
440
486
  ---
441
487
 
package/dist/cli.js CHANGED
@@ -1230,7 +1230,7 @@ async function uploadToS3(url, buffer, contentType) {
1230
1230
  const res = await fetch(url, {
1231
1231
  method: "PUT",
1232
1232
  headers: { "Content-Type": contentType },
1233
- body: buffer
1233
+ body: new Uint8Array(buffer)
1234
1234
  });
1235
1235
  if (!res.ok) {
1236
1236
  throw new Error(`S3 upload failed: ${res.status} ${res.statusText}`);
@@ -2576,7 +2576,7 @@ async function uploadToS32(url, buffer, contentType) {
2576
2576
  const res = await fetch(url, {
2577
2577
  method: "PUT",
2578
2578
  headers: { "Content-Type": contentType },
2579
- body: buffer
2579
+ body: new Uint8Array(buffer)
2580
2580
  });
2581
2581
  if (!res.ok) throw new Error(`S3 upload failed: ${res.status} ${res.statusText}`);
2582
2582
  }
@@ -3356,14 +3356,952 @@ function registerGeneratedContentCommands(program2) {
3356
3356
  });
3357
3357
  }
3358
3358
 
3359
+ // src/commands/analytics.ts
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
+
3359
4297
  // src/commands/update.ts
3360
4298
  import semver2 from "semver";
3361
- import pc10 from "picocolors";
4299
+ import pc11 from "picocolors";
3362
4300
  import { execSync } from "child_process";
3363
4301
  var NPM_PACKAGE2 = "@senso-ai/cli";
3364
4302
  function registerUpdateCommand(program2) {
3365
4303
  program2.command("update").description("Update CLI to the latest version").action(async () => {
3366
- info(`Current version: ${pc10.bold(version)}`);
4304
+ info(`Current version: ${pc11.bold(version)}`);
3367
4305
  info("Checking npm for updates...");
3368
4306
  const latest = await getLatestVersion();
3369
4307
  if (!latest) {
@@ -3374,7 +4312,7 @@ function registerUpdateCommand(program2) {
3374
4312
  success(`Already on the latest version (${version}).`);
3375
4313
  return;
3376
4314
  }
3377
- info(`New version available: ${pc10.bold(latest)}`);
4315
+ info(`New version available: ${pc11.bold(latest)}`);
3378
4316
  info("Updating...");
3379
4317
  try {
3380
4318
  execSync(`npm install -g ${NPM_PACKAGE2}@latest`, {
@@ -3384,7 +4322,7 @@ function registerUpdateCommand(program2) {
3384
4322
  } catch {
3385
4323
  error("Update failed. Please reinstall manually:");
3386
4324
  console.log(
3387
- ` ${pc10.cyan(`npm install -g ${NPM_PACKAGE2}`)}`
4325
+ ` ${pc11.cyan(`npm install -g ${NPM_PACKAGE2}`)}`
3388
4326
  );
3389
4327
  process.exit(1);
3390
4328
  }
@@ -3426,6 +4364,8 @@ registerRolesCommands(program);
3426
4364
  registerCompetitorsCommands(program);
3427
4365
  registerTrackedSourcesCommands(program);
3428
4366
  registerGeneratedContentCommands(program);
4367
+ registerAnalyticsCommands(program);
4368
+ registerIndustriesCommands(program);
3429
4369
  registerUpdateCommand(program);
3430
4370
  async function main() {
3431
4371
  const quiet = process.argv.includes("--quiet") || process.argv.includes("--output") && process.argv[process.argv.indexOf("--output") + 1] === "json";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@senso-ai/cli",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "description": "Senso CLI — Infrastructure for the Agentic Web. Interact with your Senso knowledge base from the terminal.",
5
5
  "type": "module",
6
6
  "bin": {