@pitcher/cli-scripts 2.14.0 → 2.16.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 CHANGED
@@ -12,7 +12,7 @@ p command --help
12
12
 
13
13
  ## MCP server
14
14
 
15
- Run `p mcp` to start a stdio [MCP](https://modelcontextprotocol.io) server that exposes this CLI's commands as tools to an MCP-capable host (Claude Desktop, Claude Code, etc). It wraps 114 tools, one per CLI `run*` function: every command above (including `dsr create` + `dsr bind-landing` and the full `ld` surface) except the five local `scaffold` subcommands (which just print/write a starter file and make no request). `dev-sync` is exposed as a one-shot sync only; the continuous `watch` mode is not available as a tool.
15
+ Run `p mcp` to start a stdio [MCP](https://modelcontextprotocol.io) server that exposes this CLI's commands as tools to an MCP-capable host (Claude Desktop, Claude Code, etc). It wraps 121 tools, one per CLI `run*` function: every command above (including `dsr create` + `dsr bind-landing` and the full `ld` surface) except the five local `scaffold` subcommands (which just print/write a starter file and make no request). `dev-sync` is exposed as a one-shot sync only; the continuous `watch` mode is not available as a tool.
16
16
 
17
17
  ```bash
18
18
  p mcp
@@ -55,7 +55,7 @@ yarn mcpb:pack # → pitcher.mcpb (validated + packed)
55
55
 
56
56
  Install by double-clicking `pitcher.mcpb` (or Settings → Extensions → Install Extension). Claude Desktop prompts for the API key (stored in the OS keychain) and organization; both are editable afterward, so switching orgs needs no reinstall.
57
57
 
58
- The bundle reuses the same server as `p mcp` but registers a **content-tool subset (111)** — it omits the developer-only `publish-app` and `dev-sync`/`dev-sync-revert`. The `organization` field accepts a subdomain (`acme`), a bare hostname (`acme.my.pitcher.com`), or a full `https://` URL. Build details (esbuild single-file, version injection) live in `mcpb/` and `CLAUDE.md`.
58
+ The bundle reuses the same server as `p mcp` but registers a **content-tool subset (118)** — it omits the developer-only `publish-app` and `dev-sync`/`dev-sync-revert`. The `organization` field accepts a subdomain (`acme`), a bare hostname (`acme.my.pitcher.com`), or a full `https://` URL. Build details (esbuild single-file, version injection) live in `mcpb/` and `CLAUDE.md`.
59
59
 
60
60
  This section is a reference only. For app-development conventions (modules, settings, mount points, SFDC), see the `pitcher-apps` skill, not this README.
61
61
 
@@ -394,6 +394,8 @@ p analytics viewers --instance-id 01... --canvas-id 01CANVASID... --viewer-type
394
394
  p analytics sessions --instance-id 01... --canvas-id 01CANVASID... --user rep@example.com --api-key myapikey
395
395
  ```
396
396
 
397
+ MCP: all ten verbs are exposed as read-only `pitcher_analytics_*` tools, alongside a `pitcher://analytics/catalog` resource (which verb answers which question — returns, time model, key tier) and four query prompts — `top_content`, `content_engagement_summary`, `who_viewed_canvas`, `adoption_snapshot`.
398
+
397
399
  ## To run the `users` command:
398
400
 
399
401
  Read users in the org (read-only). Org scope comes from the API key via the proxy `x-org-id` header — there is no `--org-id` flag.
@@ -11,6 +11,7 @@ export declare function runFilesList(opts: CommonOpts & {
11
11
  instanceId?: string;
12
12
  tags?: string;
13
13
  idIn?: string;
14
+ type?: string;
14
15
  page?: number;
15
16
  pageSize?: number;
16
17
  }, deps?: FilesDeps): Promise<Envelope<{
@@ -5,7 +5,7 @@ import { createCliError } from '../core/errors.js';
5
5
  import { emitEnvelope } from '../core/output.js';
6
6
  import { uploadFile } from '../core/upload.js';
7
7
  import { listPageParams, listPaginationEcho } from '../core/pagination.js';
8
- import { parseTags, parseJsonObject, validateAccessType, deepMerge, assertIntFlags } from '../schema/content.js';
8
+ import { parseTags, parseJsonObject, validateAccessType, validateFileType, deepMerge, assertIntFlags } from '../schema/content.js';
9
9
  import { withAuthFlags } from './withAuth.js';
10
10
  export async function runFilesList(
11
11
  // NOTE: no folderId — the Django files list has no folder filter (listing a
@@ -15,12 +15,25 @@ opts, deps = {}) {
15
15
  if (!opts.instanceId)
16
16
  throw createCliError('VALIDATION', '--instance-id is required for files list');
17
17
  assertIntFlags(opts);
18
+ const type = validateFileType(opts.type);
18
19
  const client = clientFor(opts, deps);
19
20
  const params = { instance_id: opts.instanceId, ...listPageParams(opts) };
20
21
  if (opts.idIn)
21
22
  params.id__in = opts.idIn;
22
23
  if (opts.tags)
23
24
  params.tags__in = opts.tags;
25
+ // Installable apps are file records too (type=app). Exclude them by default so a "list files"
26
+ // request returns real content, not the instance's installed apps. `--type <content|asset|app>`
27
+ // isolates one type and always wins; `--type all` restores the unfiltered listing. An explicit
28
+ // --id-in lookup bypasses the default exclusion — fetching a file by id is a targeted lookup, not
29
+ // a browse, so it must return that record whatever its type (an explicit --type still applies).
30
+ // Uses type__in to match the tags__in/id__in CSV convention above (a single value is a valid filter).
31
+ if (type && type !== 'all') {
32
+ params.type__in = type;
33
+ }
34
+ else if (!type && !opts.idIn) {
35
+ params.type__in = 'content,asset';
36
+ }
24
37
  const res = await client.get('/api/v1/files/', { params });
25
38
  return ok({ results: res.data.results, pagination: listPaginationEcho(opts, res.data) });
26
39
  }
@@ -122,6 +135,7 @@ export function createFilesCommand() {
122
135
  .option('--instance-id <id>', 'Instance ID (required)')
123
136
  .option('--id-in <ids>', 'Comma-separated file IDs')
124
137
  .option('--tags <tags>', 'Comma-separated tags (filters via tags__in)')
138
+ .option('--type <type>', 'File type: content, asset, app, or all (default excludes installable apps)')
125
139
  .option('--page <n>', 'Page number', (v) => parseInt(v, 10))
126
140
  .option('--page-size <n>', 'Page size (max 500)', (v) => parseInt(v, 10))
127
141
  .action(async (options, command) => {
@@ -1,7 +1,7 @@
1
1
  import { allTools } from './registry.js';
2
2
  // Developer-only tools excluded from the consumer .mcpb bundle. `publish_app` is alwaysDestructive
3
3
  // (creates a live, org-visible app release); `dev_sync`/`dev_sync_revert` mutate remote S3 dev state.
4
- // None are relevant to a non-technical adopter installing the extension. The CLI `p mcp` keeps all 114.
4
+ // None are relevant to a non-technical adopter installing the extension. The CLI `p mcp` keeps all 121.
5
5
  export const BUNDLE_EXCLUDED_TOOLS = new Set(['pitcher_publish_app', 'pitcher_dev_sync', 'pitcher_dev_sync_revert']);
6
6
  // The tool set the .mcpb bundle registers.
7
7
  export const bundledTools = allTools.filter((t) => !BUNDLE_EXCLUDED_TOOLS.has(t.name));
@@ -0,0 +1,2 @@
1
+ import type { PromptDescriptor } from './types.js';
2
+ export declare const analyticsPrompts: PromptDescriptor[];
@@ -0,0 +1,112 @@
1
+ import { z } from 'zod';
2
+ import { instanceLine, readSafetyNote } from './shared.js';
3
+ // Read-only analytics query recipes. Unlike the authoring prompts, these create nothing — each just
4
+ // routes a common question to the right pitcher_analytics_* verb(s) and asks for a concise summary.
5
+ // They carry readSafetyNote() (confirm instance + treat identities as customer data), NOT the
6
+ // content-creation safetyPreamble (no markup is authored, so the {{token}} anti-XSS guidance is N/A).
7
+ export const analyticsPrompts = [
8
+ {
9
+ name: 'top_content',
10
+ title: 'Top-performing content',
11
+ description: 'Rank an instance’s most-viewed canvases (and optionally files) over a window.',
12
+ argsSchema: {
13
+ instance_id: z.string().optional().describe('Target instance ID (leave blank to resolve interactively)'),
14
+ period: z.string().optional().describe('Time window in words, e.g. "last 30 days", "this quarter"'),
15
+ limit: z.string().optional().describe('How many top items to return (default 10)'),
16
+ },
17
+ build: (args) => {
18
+ const period = args.period || 'the last 30 days';
19
+ const limit = args.limit || '10';
20
+ const text = [
21
+ readSafetyNote(),
22
+ '',
23
+ '## Task: top-performing content',
24
+ instanceLine(args.instance_id),
25
+ `Window: ${period}.`,
26
+ '',
27
+ 'Steps:',
28
+ `1. Translate the window into ISO \`startDate\`/\`endDate\` (omit both ONLY when the window is the default last 30 days). Then call \`pitcher_analytics_popularity\` for the instance with those dates and \`limit\`=${limit} to get the most-viewed canvases, ranked.`,
29
+ '2. (Optional) Call `pitcher_analytics_files` with the same dates to add the most-used files.',
30
+ '3. Summarize the top items with their view/engagement numbers, and state the exact window used. Requires an org-admin key.',
31
+ ].join('\n');
32
+ return [{ role: 'user', content: { type: 'text', text } }];
33
+ },
34
+ },
35
+ {
36
+ name: 'content_engagement_summary',
37
+ title: 'Content engagement summary',
38
+ description: 'Summarize per-content/DSR engagement for an instance over a window.',
39
+ argsSchema: {
40
+ instance_id: z.string().optional().describe('Target instance ID (leave blank to resolve interactively)'),
41
+ period: z.string().optional().describe('Time window in words, e.g. "last 30 days"'),
42
+ },
43
+ build: (args) => {
44
+ const period = args.period || 'the last 30 days';
45
+ const text = [
46
+ readSafetyNote(),
47
+ '',
48
+ '## Task: content engagement summary',
49
+ instanceLine(args.instance_id),
50
+ `Window: ${period}.`,
51
+ '',
52
+ 'Steps:',
53
+ '1. Translate the window into ISO `startDate`/`endDate` (omit both ONLY when the window is the default last 30 days), then call `pitcher_analytics_engagement` for the instance with those dates (raise `limit` if you need more than the top 20 rows).',
54
+ '2. Read the `summary` for headline metrics and the `rows` for per-item engagement; note `has_more`/`total_count` if the list is capped.',
55
+ '3. Report which content is engaged with most and the overall trend for the window. Works with a member key.',
56
+ ].join('\n');
57
+ return [{ role: 'user', content: { type: 'text', text } }];
58
+ },
59
+ },
60
+ {
61
+ name: 'who_viewed_canvas',
62
+ title: 'Who viewed a DSR canvas',
63
+ description: 'List the viewers of a specific DSR canvas, then drill into one viewer’s session timeline.',
64
+ argsSchema: {
65
+ instance_id: z.string().optional().describe('Target instance ID (leave blank to resolve interactively)'),
66
+ canvas_id: z.string().optional().describe('Canvas to inspect (leave blank to pick one interactively)'),
67
+ },
68
+ build: (args) => {
69
+ const text = [
70
+ readSafetyNote(),
71
+ '',
72
+ '## Task: who viewed a DSR canvas',
73
+ instanceLine(args.instance_id),
74
+ args.canvas_id
75
+ ? `Canvas to inspect: ${args.canvas_id}.`
76
+ : 'No canvas given — help the user pick one via `pitcher_canvases_list` (or `pitcher_search`).',
77
+ '',
78
+ 'Steps:',
79
+ '1. Call `pitcher_analytics_viewers` with the `canvasId` to list viewers and their per-viewer engagement.',
80
+ '2. To drill into a specific viewer, call `pitcher_analytics_sessions` with the same `canvasId` plus that viewer’s ' +
81
+ '`userEmail` (both come from step 1) to get their raw session event timeline.',
82
+ '3. Summarize who viewed, how much they engaged, and — if you drilled in — what that viewer did. Works with a member key.',
83
+ ].join('\n');
84
+ return [{ role: 'user', content: { type: 'text', text } }];
85
+ },
86
+ },
87
+ {
88
+ name: 'adoption_snapshot',
89
+ title: 'Adoption snapshot',
90
+ description: 'Combine active-user stats and KPIs into a short adoption snapshot for an instance.',
91
+ argsSchema: {
92
+ instance_id: z.string().optional().describe('Target instance ID (leave blank to resolve interactively)'),
93
+ period: z.string().optional().describe('Time window in words, e.g. "last 30 days"'),
94
+ },
95
+ build: (args) => {
96
+ const period = args.period || 'the last 30 days';
97
+ const text = [
98
+ readSafetyNote(),
99
+ '',
100
+ '## Task: adoption snapshot',
101
+ instanceLine(args.instance_id),
102
+ `Window: ${period}.`,
103
+ '',
104
+ 'Steps:',
105
+ '1. Translate the window into ISO `startDate`/`endDate` (omit both ONLY when the window is the default last 30 days), then call `pitcher_analytics_users` with `stats`=true and those dates for active-user statistics.',
106
+ '2. Call `pitcher_analytics_kpi` with the same dates for headline counts (e.g. `type`=total-active-users, then total-members for the denominator).',
107
+ '3. Combine into a short adoption snapshot — active users, totals, and the active/total ratio for the window. Requires an org-admin key.',
108
+ ].join('\n');
109
+ return [{ role: 'user', content: { type: 'text', text } }];
110
+ },
111
+ },
112
+ ];
@@ -3,6 +3,7 @@ import { dsrLandingPrompts } from './dsrLanding.js';
3
3
  import { dsrPrompts } from './dsr.js';
4
4
  import { announcementsPrompts } from './announcements.js';
5
5
  import { ldCanvasCoursePrompts } from './ldCanvasCourse.js';
6
+ import { analyticsPrompts } from './analytics.js';
6
7
  // Every recipe prompt the server can expose. Mirrors mcp/registry.ts (allTools).
7
8
  export const allPrompts = [
8
9
  ...emailTemplatePrompts,
@@ -10,4 +11,5 @@ export const allPrompts = [
10
11
  ...dsrPrompts,
11
12
  ...announcementsPrompts,
12
13
  ...ldCanvasCoursePrompts,
14
+ ...analyticsPrompts,
13
15
  ];
@@ -1,3 +1,4 @@
1
1
  export declare function instanceLine(instanceId: string | undefined): string;
2
2
  export declare function safetyPreamble(): string;
3
+ export declare function readSafetyNote(): string;
3
4
  export declare function recipePreamble(): string;
@@ -24,6 +24,17 @@ export function safetyPreamble() {
24
24
  'the generated markup.',
25
25
  ].join('\n');
26
26
  }
27
+ // Lighter note for READ-ONLY recipes (analytics queries): they create nothing, so they don't carry
28
+ // the content-creation prod-warning or the {{token}}/anti-XSS guidance (there is no markup to author).
29
+ // Instead: confirm the instance is the intended one, and treat returned identities as customer data.
30
+ export function readSafetyNote() {
31
+ return [
32
+ '## Before you query',
33
+ 'These are **read-only** analytics tools — they create nothing and mutate nothing. Confirm you are ' +
34
+ 'pointed at the intended instance before reporting numbers, and treat any returned user ' +
35
+ 'identities/emails as customer data (handle per policy).',
36
+ ].join('\n');
37
+ }
27
38
  // Full preamble for HTML-authoring recipes: the design principles + the safety guidance.
28
39
  // Authored once so the principles, prod-awareness, and anti-XSS guidance can't drift.
29
40
  export function recipePreamble() {
@@ -0,0 +1 @@
1
+ export declare const ANALYTICS_RENDER_POLICY = "When answering Pitcher content-analytics questions (the pitcher_analytics_* tools), return the smallest sufficient representation. Answer in words first; a visual supports the sentence, it never replaces it. Default to prose and escalate ONLY when a trigger below fires.\n\nRendering tiers (evaluate top-down, stop at the first match):\n- A single fact / yes-no / \"who/what/how many is the single X\" (e.g. \"which canvas did I view?\", \"how many viewers?\") -> 1-2 sentences with the number inline. No table, no chart.\n- A ranking request (\"top\", \"most\", \"best\", \"rank\", or \"list the highest/lowest ...\") about content, files, users, or canvases -> a markdown TABLE of the ranked rows (default the top 5-8); columns = only the metric(s) asked for. Treat \"what's my top content?\" as a ranking (a table), NOT a single-item sentence. Lead with a one-line takeaway, then the table.\n- \"over time\" / \"trend\" / \"by week/month\" -> ONE line/area chart artifact (this rule wins over the generic comparison rule below for time series).\n- An explicit visualization request (\"show a chart\", \"chart\", \"visualize\", \"graph\", \"plot\") OR a comparison across a non-time dimension -> ONE focused chart as a single self-contained HTML artifact, only the asked-for series.\n- A broad overview (\"how's everything / my content / adoption doing\", \"give me the picture\", \"dashboard\", QBR) -> a multi-panel dashboard artifact. This is the ceiling \u2014 use it rarely, only for genuinely broad asks.\n\nGuardrails (every tier):\n1. Words first \u2014 even with a chart, lead with a one-sentence answer to the literal question.\n2. Mask names, emails, and user IDs by default (User A/B/...). Reveal an identity only when the analytics tool actually returned it for the authenticated role (the verbs are role-gated); never infer authorization from the user's wording.\n3. If data is empty or trivially small, say so in prose \u2014 do not render an empty chart.\n4. If results are capped/truncated, state the cap (\"top 15 of 118\").\n5. Never show a \"vs prior period\" delta unless the data actually contains prior-period numbers.\n6. Cite the source verb (\"via analytics popularity\").\n\nDo not jump a tier without its trigger. The dashboard is opt-in by intent, never a default.";
@@ -0,0 +1,23 @@
1
+ // Connector-level MCP instructions surfaced to the host model (Claude Desktop, Cursor) via
2
+ // InitializeResult.instructions. Governs how analytics answers are RENDERED so a natural-language
3
+ // question gets a right-sized reply (a sentence, a table, one chart, or — rarely — a dashboard)
4
+ // instead of a rigid full dashboard on every question. This constant is the single source of truth;
5
+ // the Docs Portal spec (Product/Specs/2026-07-24-analytics-answer-rendering-policy.md) mirrors it.
6
+ export const ANALYTICS_RENDER_POLICY = `When answering Pitcher content-analytics questions (the pitcher_analytics_* tools), return the smallest sufficient representation. Answer in words first; a visual supports the sentence, it never replaces it. Default to prose and escalate ONLY when a trigger below fires.
7
+
8
+ Rendering tiers (evaluate top-down, stop at the first match):
9
+ - A single fact / yes-no / "who/what/how many is the single X" (e.g. "which canvas did I view?", "how many viewers?") -> 1-2 sentences with the number inline. No table, no chart.
10
+ - A ranking request ("top", "most", "best", "rank", or "list the highest/lowest ...") about content, files, users, or canvases -> a markdown TABLE of the ranked rows (default the top 5-8); columns = only the metric(s) asked for. Treat "what's my top content?" as a ranking (a table), NOT a single-item sentence. Lead with a one-line takeaway, then the table.
11
+ - "over time" / "trend" / "by week/month" -> ONE line/area chart artifact (this rule wins over the generic comparison rule below for time series).
12
+ - An explicit visualization request ("show a chart", "chart", "visualize", "graph", "plot") OR a comparison across a non-time dimension -> ONE focused chart as a single self-contained HTML artifact, only the asked-for series.
13
+ - A broad overview ("how's everything / my content / adoption doing", "give me the picture", "dashboard", QBR) -> a multi-panel dashboard artifact. This is the ceiling — use it rarely, only for genuinely broad asks.
14
+
15
+ Guardrails (every tier):
16
+ 1. Words first — even with a chart, lead with a one-sentence answer to the literal question.
17
+ 2. Mask names, emails, and user IDs by default (User A/B/...). Reveal an identity only when the analytics tool actually returned it for the authenticated role (the verbs are role-gated); never infer authorization from the user's wording.
18
+ 3. If data is empty or trivially small, say so in prose — do not render an empty chart.
19
+ 4. If results are capped/truncated, state the cap ("top 15 of 118").
20
+ 5. Never show a "vs prior period" delta unless the data actually contains prior-period numbers.
21
+ 6. Cite the source verb ("via analytics popularity").
22
+
23
+ Do not jump a tier without its trigger. The dashboard is opt-in by intent, never a default.`;
@@ -0,0 +1,3 @@
1
+ import type { ResourceDescriptor } from './types.js';
2
+ export declare const ANALYTICS_CATALOG = "# Pitcher analytics catalog\n\nTen read-only verbs, each an MCP tool named `pitcher_analytics_<verb>`. Pick the verb that matches the\nquestion, then call its tool. All are read-only; org scope + auth come from the server's API key (never a\ntool input) \u2014 you only pass `instanceId` (and, where noted, `canvasId`/`userEmail`).\n\n## Key tiers\n- **org-admin key required:** org, popularity, users, files, canvases\n- **instance-admin or org-admin:** instance\n- **member key OK (access follows the key user's real role):** kpi, engagement, viewers, sessions\n\n## Time model\n- **Rollup (no window):** org, instance \u2014 lifetime/current totals.\n- **Date-range, 30-day default:** kpi (activity types need dates), engagement, popularity, `users` with\n `stats: true`, files, canvases \u2014 omit dates to get the last 30 days. (`users` without `stats` is a\n full roster with no window.)\n- **Date-range, server default window:** viewers, sessions \u2014 omit dates and the service picks the window.\n\n## Verbs\n\n| Verb (tool) | Returns | Time | Key | Example question |\n|---|---|---|---|---|\n| `org` | Organization-wide rollup totals (Django) | rollup | org-admin | \"How is the whole org doing overall?\" |\n| `instance` | Single-instance rollup totals (Django) | rollup | instance-admin | \"Totals for this one workspace?\" |\n| `kpi` | KPI metrics for an instance (members, active users, \u2026) | date-range | member | \"How many active users last month?\" |\n| `engagement` | Per-DSR/content engagement rows (capped list + summary) | date-range | member | \"Which content is being engaged with?\" |\n| `popularity` | Most-viewed canvases, ranked (view-analysis) | date-range | org-admin | \"Top 10 most-viewed decks this quarter?\" |\n| `users` | User roster, or active-user stats with `stats: true` | roster: none / stats: date-range | org-admin | \"Who are my active users this month?\" |\n| `files` | File usage analytics (views/opens/engagement per file) | date-range | org-admin | \"Which files get opened the most?\" |\n| `canvases` | Per-section canvas engagement list | date-range | org-admin | \"How are my canvas sections performing?\" |\n| `viewers` | Who viewed a specific DSR canvas (per-viewer engagement) | date-range | member | \"Who viewed this DSR, and how much?\" |\n| `sessions` | One viewer's session event timeline for a DSR canvas (raw) | date-range | member | \"What did this viewer do inside the DSR?\" |\n\n## Shaping\nRow-heavy verbs (engagement, popularity, users, files, canvases, viewers) return a capped envelope:\n`summary` + `rows` (default 20, max 100 \u2014 raise with `limit`) + `total_count`/`returned`/`has_more`,\nand a `truncated` note when more rows exist. `kpi` and `sessions` return the raw payload (a KPI scalar set\nand an uncapped event timeline, respectively) \u2014 never truncated.\n\n## DSR drill-down\nFor \"who viewed X and what did they do\": call `viewers` (with `canvasId`) to list viewers, then `sessions`\n(with the same `canvasId` + the viewer's `userEmail`) to see that viewer's event timeline.\n\n## Notes\n- `popularity` maps to the canvases view-analysis endpoint (there is no separate popularity route).\n- Results can include user identities/emails \u2014 treat as customer data and handle per policy.\n";
3
+ export declare const analyticsCatalogResource: ResourceDescriptor;
@@ -0,0 +1,60 @@
1
+ // A model-facing catalog of the analytics verbs exposed as pitcher_analytics_* tools. It exists so a
2
+ // host can read ONE resource to learn which verb answers which question, the time model each uses, and
3
+ // the key tier each needs — instead of guessing from 10 tool descriptions. Distilled from the analytics
4
+ // runner contracts in src/commands/analytics.ts (the authoritative source); keep in sync on route/gate
5
+ // changes. Definitions mirror the platform analytics vocabulary.
6
+ export const ANALYTICS_CATALOG = `# Pitcher analytics catalog
7
+
8
+ Ten read-only verbs, each an MCP tool named \`pitcher_analytics_<verb>\`. Pick the verb that matches the
9
+ question, then call its tool. All are read-only; org scope + auth come from the server's API key (never a
10
+ tool input) — you only pass \`instanceId\` (and, where noted, \`canvasId\`/\`userEmail\`).
11
+
12
+ ## Key tiers
13
+ - **org-admin key required:** org, popularity, users, files, canvases
14
+ - **instance-admin or org-admin:** instance
15
+ - **member key OK (access follows the key user's real role):** kpi, engagement, viewers, sessions
16
+
17
+ ## Time model
18
+ - **Rollup (no window):** org, instance — lifetime/current totals.
19
+ - **Date-range, 30-day default:** kpi (activity types need dates), engagement, popularity, \`users\` with
20
+ \`stats: true\`, files, canvases — omit dates to get the last 30 days. (\`users\` without \`stats\` is a
21
+ full roster with no window.)
22
+ - **Date-range, server default window:** viewers, sessions — omit dates and the service picks the window.
23
+
24
+ ## Verbs
25
+
26
+ | Verb (tool) | Returns | Time | Key | Example question |
27
+ |---|---|---|---|---|
28
+ | \`org\` | Organization-wide rollup totals (Django) | rollup | org-admin | "How is the whole org doing overall?" |
29
+ | \`instance\` | Single-instance rollup totals (Django) | rollup | instance-admin | "Totals for this one workspace?" |
30
+ | \`kpi\` | KPI metrics for an instance (members, active users, …) | date-range | member | "How many active users last month?" |
31
+ | \`engagement\` | Per-DSR/content engagement rows (capped list + summary) | date-range | member | "Which content is being engaged with?" |
32
+ | \`popularity\` | Most-viewed canvases, ranked (view-analysis) | date-range | org-admin | "Top 10 most-viewed decks this quarter?" |
33
+ | \`users\` | User roster, or active-user stats with \`stats: true\` | roster: none / stats: date-range | org-admin | "Who are my active users this month?" |
34
+ | \`files\` | File usage analytics (views/opens/engagement per file) | date-range | org-admin | "Which files get opened the most?" |
35
+ | \`canvases\` | Per-section canvas engagement list | date-range | org-admin | "How are my canvas sections performing?" |
36
+ | \`viewers\` | Who viewed a specific DSR canvas (per-viewer engagement) | date-range | member | "Who viewed this DSR, and how much?" |
37
+ | \`sessions\` | One viewer's session event timeline for a DSR canvas (raw) | date-range | member | "What did this viewer do inside the DSR?" |
38
+
39
+ ## Shaping
40
+ Row-heavy verbs (engagement, popularity, users, files, canvases, viewers) return a capped envelope:
41
+ \`summary\` + \`rows\` (default 20, max 100 — raise with \`limit\`) + \`total_count\`/\`returned\`/\`has_more\`,
42
+ and a \`truncated\` note when more rows exist. \`kpi\` and \`sessions\` return the raw payload (a KPI scalar set
43
+ and an uncapped event timeline, respectively) — never truncated.
44
+
45
+ ## DSR drill-down
46
+ For "who viewed X and what did they do": call \`viewers\` (with \`canvasId\`) to list viewers, then \`sessions\`
47
+ (with the same \`canvasId\` + the viewer's \`userEmail\`) to see that viewer's event timeline.
48
+
49
+ ## Notes
50
+ - \`popularity\` maps to the canvases view-analysis endpoint (there is no separate popularity route).
51
+ - Results can include user identities/emails — treat as customer data and handle per policy.
52
+ `;
53
+ export const analyticsCatalogResource = {
54
+ name: 'analytics-catalog',
55
+ uri: 'pitcher://analytics/catalog',
56
+ title: 'Analytics verb catalog',
57
+ description: 'Which analytics verb answers which question — returns, time model, key tier, examples.',
58
+ mimeType: 'text/markdown',
59
+ read: () => ANALYTICS_CATALOG,
60
+ };
@@ -1,4 +1,9 @@
1
1
  import { contentDesignResource } from './contentDesign.js';
2
2
  import { ldCanvasDesignResource } from './ldCanvasDesign.js';
3
+ import { analyticsCatalogResource } from './analyticsCatalog.js';
3
4
  // Every resource the server can expose. Mirrors mcp/registry.ts (allTools).
4
- export const allResources = [contentDesignResource, ldCanvasDesignResource];
5
+ export const allResources = [
6
+ contentDesignResource,
7
+ ldCanvasDesignResource,
8
+ analyticsCatalogResource,
9
+ ];
@@ -6,6 +6,7 @@ import { allTools } from './registry.js';
6
6
  import { allPrompts } from './prompts/registry.js';
7
7
  import { allResources } from './resources/registry.js';
8
8
  import { envelopeToToolResult, toErrorResult } from './result.js';
9
+ import { ANALYTICS_RENDER_POLICY } from './renderPolicy.js';
9
10
  import { isCliError } from '../core/errors.js';
10
11
  const version = (() => {
11
12
  // The .mcpb bundle inlines this at build time (esbuild define) because the bundled single
@@ -40,7 +41,11 @@ export function buildServer(env, tools = allTools, prompts = allPrompts, resourc
40
41
  description: 'Search, manage, and share Pitcher content, canvases, files, DSRs, and instances.',
41
42
  websiteUrl: 'https://www.pitcher.com',
42
43
  icons: [{ src: icon, mimeType: 'image/svg+xml', sizes: ['any'] }],
43
- });
44
+ },
45
+ // Surfaced to the host model via InitializeResult.instructions — the analytics answer-rendering
46
+ // policy so a natural-language question gets a right-sized reply (sentence / table / chart /
47
+ // dashboard) instead of a rigid full dashboard every time. See renderPolicy.ts.
48
+ { instructions: ANALYTICS_RENDER_POLICY });
44
49
  for (const tool of tools) {
45
50
  const destructive = tool.alwaysDestructive === true || (tool.write === true && env.prod);
46
51
  server.registerTool(tool.name, {
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { runAnalyticsOrg, runAnalyticsInstance, runAnalyticsKpi } from '../../commands/analytics.js';
2
+ import { runAnalyticsOrg, runAnalyticsInstance, runAnalyticsKpi, runAnalyticsEngagement, runAnalyticsPopularity, runAnalyticsUsers, runAnalyticsFiles, runAnalyticsCanvases, runAnalyticsViewers, runAnalyticsSessions, } from '../../commands/analytics.js';
3
3
  export const analyticsTools = [
4
4
  {
5
5
  name: 'pitcher_analytics_org',
@@ -34,4 +34,141 @@ export const analyticsTools = [
34
34
  run: runAnalyticsKpi,
35
35
  readOnly: true,
36
36
  },
37
+ {
38
+ name: 'pitcher_analytics_engagement',
39
+ title: 'DSR/content engagement',
40
+ description: 'Per-DSR/content engagement rows for an instance from the clickhouse analytics service. Returns a ' +
41
+ 'summary plus a list capped to `limit` (with total_count/has_more). Works with a member key. ' +
42
+ 'Defaults to a 30-day window when dates are omitted.',
43
+ inputShape: {
44
+ instanceId: z.string().describe('Instance ID (required)'),
45
+ startDate: z.string().optional().describe('Window start, ISO date (defaults to 30 days ago)'),
46
+ endDate: z.string().optional().describe('Window end, ISO date (defaults to today)'),
47
+ engagementLevel: z.string().optional().describe("Engagement level filter (default 'all')"),
48
+ view: z.string().optional().describe("View context (default 'dsr')"),
49
+ search: z.string().optional().describe('Search term'),
50
+ groupIds: z.string().optional().describe('Comma-separated distribution-group IDs'),
51
+ sortBy: z.string().optional().describe('Sort field'),
52
+ sortOrder: z.string().optional().describe('Sort direction (asc/desc)'),
53
+ hideMemberFiles: z.boolean().optional().describe('Exclude rep-uploaded files'),
54
+ page: z.number().int().optional().describe('Page number'),
55
+ limit: z.number().int().optional().describe('Max rows to return (default 20, max 100)'),
56
+ },
57
+ run: runAnalyticsEngagement,
58
+ readOnly: true,
59
+ },
60
+ {
61
+ name: 'pitcher_analytics_popularity',
62
+ title: 'Most-viewed canvases',
63
+ description: 'Most-viewed canvases (view-analysis) for an instance, ranked and capped to `limit`. Requires an ' +
64
+ 'org-admin key. Defaults to a 30-day window when dates are omitted.',
65
+ inputShape: {
66
+ instanceId: z.string().describe('Instance ID (required)'),
67
+ startDate: z.string().optional().describe('Window start, ISO date (defaults to 30 days ago)'),
68
+ endDate: z.string().optional().describe('Window end, ISO date (defaults to today)'),
69
+ sortBy: z.string().optional().describe("Sort field (default 'total_views')"),
70
+ sortOrder: z.string().optional().describe("Sort direction (default 'DESC')"),
71
+ groupIds: z.string().optional().describe('Comma-separated distribution-group IDs'),
72
+ page: z.number().int().optional().describe('Page number'),
73
+ limit: z.number().int().optional().describe('Max rows to return (default 20, max 100)'),
74
+ },
75
+ run: runAnalyticsPopularity,
76
+ readOnly: true,
77
+ },
78
+ {
79
+ name: 'pitcher_analytics_users',
80
+ title: 'User roster / active-user stats',
81
+ description: 'User roster (default) or active-user statistics (`stats: true`) for an instance. Requires an ' +
82
+ 'org-admin key. The stats branch defaults to a 30-day window when dates are omitted.',
83
+ inputShape: {
84
+ instanceId: z.string().describe('Instance ID (required)'),
85
+ stats: z.boolean().optional().describe('Return active-user statistics instead of the roster'),
86
+ startDate: z.string().optional().describe('Window start, ISO date (stats only, defaults to 30 days ago)'),
87
+ endDate: z.string().optional().describe('Window end, ISO date (stats only, defaults to today)'),
88
+ groupIds: z.string().optional().describe('Comma-separated distribution-group IDs (stats only)'),
89
+ page: z.number().int().optional().describe('Page number (stats only)'),
90
+ limit: z.number().int().optional().describe('Max rows to return (default 20, max 100)'),
91
+ },
92
+ run: runAnalyticsUsers,
93
+ readOnly: true,
94
+ },
95
+ {
96
+ name: 'pitcher_analytics_files',
97
+ title: 'File usage analytics',
98
+ description: 'File usage analytics for an instance (views/opens/engagement per file), summarized and capped to ' +
99
+ '`limit`. Requires an org-admin key. Defaults to a 30-day window when dates are omitted.',
100
+ inputShape: {
101
+ instanceId: z.string().describe('Instance ID (required)'),
102
+ startDate: z.string().optional().describe('Window start, ISO date (defaults to 30 days ago)'),
103
+ endDate: z.string().optional().describe('Window end, ISO date (defaults to today)'),
104
+ fileType: z.string().optional().describe('Filter by file type'),
105
+ filter: z.string().optional().describe('Search term'),
106
+ sortBy: z.string().optional().describe('Sort field'),
107
+ sortOrder: z.string().optional().describe('Sort direction (asc/desc; normalized to ASC/DESC)'),
108
+ metadataFilters: z.string().optional().describe('Metadata filters (JSON string)'),
109
+ groupIds: z.string().optional().describe('Comma-separated distribution-group IDs'),
110
+ userEmails: z.string().optional().describe('Comma-separated user emails to scope by'),
111
+ hideMemberFiles: z.boolean().optional().describe('Exclude rep-uploaded files'),
112
+ page: z.number().int().optional().describe('Page number'),
113
+ limit: z.number().int().optional().describe('Max rows to return (default 20, max 100)'),
114
+ },
115
+ run: runAnalyticsFiles,
116
+ readOnly: true,
117
+ },
118
+ {
119
+ name: 'pitcher_analytics_canvases',
120
+ title: 'Per-section canvas engagement',
121
+ description: 'Per-section canvas engagement list for an instance, summarized and capped to `limit`. Requires an ' +
122
+ 'org-admin key. Defaults to a 30-day window when dates are omitted.',
123
+ inputShape: {
124
+ instanceId: z.string().describe('Instance ID (required)'),
125
+ startDate: z.string().optional().describe('Window start, ISO date (defaults to 30 days ago)'),
126
+ endDate: z.string().optional().describe('Window end, ISO date (defaults to today)'),
127
+ groupIds: z.string().optional().describe('Comma-separated distribution-group IDs'),
128
+ page: z.number().int().optional().describe('Page number'),
129
+ limit: z.number().int().optional().describe('Max rows to return (default 20, max 100)'),
130
+ },
131
+ run: runAnalyticsCanvases,
132
+ readOnly: true,
133
+ },
134
+ {
135
+ name: 'pitcher_analytics_viewers',
136
+ title: 'DSR canvas viewers',
137
+ description: 'Who viewed a specific DSR canvas — a viewer list with per-viewer engagement, capped to `limit`. ' +
138
+ 'Works with a member key. Requires canvasId. The server applies its own default window when dates ' +
139
+ 'are omitted. Drill into one viewer with pitcher_analytics_sessions.',
140
+ inputShape: {
141
+ instanceId: z.string().describe('Instance ID (required)'),
142
+ canvasId: z.string().describe('Canvas ID (required)'),
143
+ viewerType: z.string().optional().describe("Viewer type filter: all, internal, external (default 'all')"),
144
+ sortBy: z
145
+ .string()
146
+ .optional()
147
+ .describe('Sort field: email, session_count, total_engagement_time, last_active, sections_viewed, files_viewed'),
148
+ sortOrder: z.string().optional().describe("Sort direction (asc/desc, default 'desc')"),
149
+ search: z.string().optional().describe('Search term'),
150
+ startDate: z.string().optional().describe('Window start, ISO date (server default applies if omitted)'),
151
+ endDate: z.string().optional().describe('Window end, ISO date (server default applies if omitted)'),
152
+ page: z.number().int().optional().describe('Page number'),
153
+ limit: z.number().int().optional().describe('Max rows to return (default 20, max 100)'),
154
+ },
155
+ run: runAnalyticsViewers,
156
+ readOnly: true,
157
+ },
158
+ {
159
+ name: 'pitcher_analytics_sessions',
160
+ title: 'DSR per-viewer session timeline',
161
+ description: 'Per-viewer session event timeline for a DSR canvas (drill-down for one viewer). Returns the raw, ' +
162
+ 'uncapped event list (not summarized). Works with a member key. Requires canvasId and userEmail — ' +
163
+ 'get both from pitcher_analytics_viewers.',
164
+ inputShape: {
165
+ instanceId: z.string().describe('Instance ID (required)'),
166
+ canvasId: z.string().describe('Canvas ID (required)'),
167
+ userEmail: z.string().describe('Viewer user email (required)'),
168
+ startDate: z.string().optional().describe('Window start, ISO date (server default applies if omitted)'),
169
+ endDate: z.string().optional().describe('Window end, ISO date (server default applies if omitted)'),
170
+ },
171
+ run: runAnalyticsSessions,
172
+ readOnly: true,
173
+ },
37
174
  ];
@@ -1,14 +1,21 @@
1
1
  import { z } from 'zod';
2
2
  import { runFilesList, runFilesGet, runFilesCreate, runFilesUpdate } from '../../commands/files.js';
3
+ import { FILE_TYPES } from '../../schema/content.js';
3
4
  export const filesTools = [
4
5
  {
5
6
  name: 'pitcher_files_list',
6
7
  title: 'List files',
7
- description: 'List content files in an instance. Filter by tags or specific IDs.',
8
+ description: 'List content files in an instance. Installed apps are stored as file records too, but are ' +
9
+ 'EXCLUDED by default (content + assets only) so a "list files" request returns real content. ' +
10
+ 'Pass type="app" for installed apps only, or type="all" to include everything. Filter by tags or specific IDs.',
8
11
  inputShape: {
9
12
  instanceId: z.string().describe('Instance ID (required)'),
10
13
  idIn: z.string().optional().describe('Comma-separated file IDs'),
11
14
  tags: z.string().optional().describe('Comma-separated tags'),
15
+ type: z
16
+ .enum(FILE_TYPES)
17
+ .optional()
18
+ .describe('File type filter. Omit to list content + assets (installed apps excluded); "app" = apps only; "all" = everything including apps.'),
12
19
  page: z.number().int().optional(),
13
20
  pageSize: z.number().int().optional(),
14
21
  },
@@ -1,8 +1,10 @@
1
+ export declare const FILE_TYPES: readonly ["content", "asset", "app", "all"];
1
2
  export declare function isPlainObject(v: unknown): v is Record<string, unknown>;
2
3
  export declare function parseJsonObject(raw: string, flagName: string): Record<string, unknown>;
3
4
  export declare function parseJsonArray(raw: string, flagName: string): unknown[];
4
5
  export declare function parseTags(raw: string): string[];
5
6
  export declare function validateAccessType(v: string | undefined): string | undefined;
7
+ export declare function validateFileType(v: string | undefined): string | undefined;
6
8
  export declare function assertIntFlags(opts: {
7
9
  page?: number;
8
10
  pageSize?: number;
@@ -1,5 +1,10 @@
1
1
  import { createCliError } from '../core/errors.js';
2
2
  const ACCESS_TYPES = ['public', 'restricted', 'personal'];
3
+ // File record types on Django's `pitcher_file.type` column, plus the `all` escape hatch.
4
+ // Installable apps are stored as file records (type=app), so `p files list` excludes them by default;
5
+ // `all` opts back into the unfiltered listing. See validateFileType / runFilesList.
6
+ // Exported so the MCP `pitcher_files_list` tool sources its zod enum from the same list (no drift).
7
+ export const FILE_TYPES = ['content', 'asset', 'app', 'all'];
3
8
  export function isPlainObject(v) {
4
9
  return typeof v === 'object' && v !== null && !Array.isArray(v);
5
10
  }
@@ -37,6 +42,14 @@ export function validateAccessType(v) {
37
42
  }
38
43
  return v;
39
44
  }
45
+ export function validateFileType(v) {
46
+ if (v === undefined)
47
+ return undefined;
48
+ if (!FILE_TYPES.includes(v)) {
49
+ throw createCliError('VALIDATION', `--type must be one of: ${FILE_TYPES.join(', ')}`);
50
+ }
51
+ return v;
52
+ }
40
53
  // Guards against NaN reaching the request (commander's parseInt yields NaN on
41
54
  // non-numeric input, e.g. `--page abc`).
42
55
  export function assertIntFlags(opts) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pitcher/cli-scripts",
3
- "version": "2.14.0",
3
+ "version": "2.16.0",
4
4
  "description": "The Pitcher CLI for various tasks in its ecosystem.",
5
5
  "author": "Pitcher AG",
6
6
  "license": "MIT",