pattern-mcp 0.3.0 → 0.5.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.
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Opt-in, anonymous product telemetry.
3
+ *
4
+ * Off by default. Enabling it (PATTERN_TELEMETRY=1) answers two questions
5
+ * the product can't answer any other way without asking users directly:
6
+ *
7
+ * - Do people come back and use Pattern on a second or third project on
8
+ * their own, unprompted? (tracked via distinct project hashes seen per
9
+ * anonymous install, on every recommend_component call)
10
+ * - How often does a BYO Anthropic key actually run dry or get rate
11
+ * limited in real sessions, not just the one time it happened during
12
+ * manual testing? (tracked via captureApiError)
13
+ *
14
+ * What gets sent, when enabled: an anonymous, randomly generated install
15
+ * ID (see installId() below); a one-way SHA-256 hash of project_id,
16
+ * truncated to 16 hex chars -- never the raw project_id string; the verdict
17
+ * shape already written to the local call log (verdict, confidence,
18
+ * ensemble_triggered, estimated cost); and, on a failed Anthropic API call,
19
+ * only the HTTP status and a coarse error classification (rate_limit /
20
+ * insufficient_credit / other) -- never the request or response body.
21
+ * component_need text, requirements_checked evidence, and the API key
22
+ * itself are never sent. See SECURITY.md and README.md for the full
23
+ * disclosure and the exact opt-in instructions.
24
+ *
25
+ * Reuses Pattern's existing PostHog project (the same one the marketing
26
+ * site sends browser events to) with its public, write-only project key --
27
+ * safe to embed in a distributed package the same way that key is already
28
+ * embedded in the site's client bundle. CLI events are namespaced with a
29
+ * "pattern_cli_" event prefix and source: "cli" so they're never confused
30
+ * with website traffic in queries or dashboards.
31
+ */
32
+ import { PostHog } from "posthog-node";
33
+ import { createHash, randomUUID } from "node:crypto";
34
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
35
+ import { homedir } from "node:os";
36
+ import { dirname, join } from "node:path";
37
+ // Opt-in, not opt-out -- deliberate, given who this tool is for. See
38
+ // README's telemetry section: a local-first tool aimed at developers who
39
+ // notice and care about silent tracking is exactly the audience §06 of the
40
+ // product brief already flags as sensitive to "no paper trail" trust gaps.
41
+ // Any of "1", "true", "yes" (case-insensitive) turns it on.
42
+ const TELEMETRY_ENABLED = /^(1|true|yes)$/i.test(process.env.PATTERN_TELEMETRY ?? "");
43
+ // One-time startup notice, printed to stderr -- the closest thing to an
44
+ // opt-in prompt an MCP stdio server can safely show. stdin is the JSON-RPC
45
+ // channel the client uses to talk to this process; blocking on it to read
46
+ // a y/n keypress would fight the protocol handshake instead of showing a
47
+ // dialog, so there's no safe way to do an interactive prompt here. This
48
+ // prints once ever (gated by TELEMETRY_NOTICE_PATH, not by whether this is
49
+ // a fresh install), so someone who installed Pattern before telemetry
50
+ // existed sees it exactly once on their first run after upgrading, the
51
+ // same as a brand-new install does on its first run ever. Call from
52
+ // main() at startup -- never from inside a tool call, so it can't be
53
+ // mistaken for a response to the calling agent.
54
+ export function printTelemetryNoticeOnce() {
55
+ try {
56
+ readFileSync(TELEMETRY_NOTICE_PATH, "utf8");
57
+ return; // Already shown -- never repeat.
58
+ }
59
+ catch {
60
+ // No marker yet -- fall through and show it.
61
+ }
62
+ const status = TELEMETRY_ENABLED
63
+ ? "ON, because PATTERN_TELEMETRY is set"
64
+ : "OFF (the default -- nothing is sent unless you opt in)";
65
+ console.error([
66
+ "",
67
+ "Pattern -- one-time telemetry notice (this will not print again)",
68
+ `Anonymous usage telemetry is currently ${status}.`,
69
+ "",
70
+ "When enabled, Pattern sends an anonymous per-install ID, a one-way",
71
+ "hash of project_id (never the raw string), and the same verdict",
72
+ "summary already written to ~/.pattern/calls.log (verdict,",
73
+ "confidence, reason, estimated cost). component_need, domain,",
74
+ "framework, existing_stack, and your API key are never sent.",
75
+ "Full field list: https://github.com/donaldrichard19-LVD/pattern-mcp#telemetry",
76
+ "",
77
+ "To help improve Pattern by sharing anonymous usage data, opt in:",
78
+ " PATTERN_TELEMETRY=1",
79
+ "Already on and want it off instead? Unset PATTERN_TELEMETRY (or set it to 0).",
80
+ "",
81
+ ].join("\n"));
82
+ try {
83
+ mkdirSync(dirname(TELEMETRY_NOTICE_PATH), { recursive: true });
84
+ writeFileSync(TELEMETRY_NOTICE_PATH, new Date().toISOString(), "utf8");
85
+ }
86
+ catch {
87
+ // Couldn't persist the marker -- worst case this prints again next
88
+ // run. Never blocks startup or a tool call over it.
89
+ }
90
+ }
91
+ // Public PostHog project API key (phc_...). Write-only: it can send events,
92
+ // it cannot read or query data back, so it's safe to ship in source the
93
+ // same way it's already shipped in the marketing site's client bundle.
94
+ // Override for self-hosting or testing against a different project.
95
+ const POSTHOG_KEY = process.env.PATTERN_POSTHOG_KEY ?? "phc_yUq5SpVfS9JxMm6QgFAYAfwzszAvbHQQsdN4xAqqJt3U";
96
+ const POSTHOG_HOST = process.env.PATTERN_POSTHOG_HOST ?? "https://us.i.posthog.com";
97
+ const INSTALL_ID_PATH = process.env.PATTERN_INSTALL_ID_PATH ?? join(homedir(), ".pattern", "install_id");
98
+ // Marker for the one-time startup notice below -- deliberately a separate
99
+ // file from install_id, not reused as an existence check. install_id gets
100
+ // created the moment ANY telemetry function runs (including a disabled
101
+ // no-op path in some future refactor); this marker exists purely to answer
102
+ // "has this specific human seen the notice yet," so it's written only from
103
+ // printTelemetryNoticeOnce itself.
104
+ const TELEMETRY_NOTICE_PATH = process.env.PATTERN_TELEMETRY_NOTICE_PATH ?? join(homedir(), ".pattern", "telemetry_notice_shown");
105
+ let cachedInstallId;
106
+ // Stable per-install anonymous ID, generated once and persisted locally --
107
+ // the distinct_id every telemetry event is keyed by. This is what makes
108
+ // "same install, second project" observable at all; without it every event
109
+ // would look like a brand-new anonymous user. Never derived from anything
110
+ // that identifies a person or machine (no hostname, no MAC, no username) --
111
+ // purely a random UUID with no way to reverse it to an identity.
112
+ function installId() {
113
+ if (cachedInstallId)
114
+ return cachedInstallId;
115
+ try {
116
+ cachedInstallId = readFileSync(INSTALL_ID_PATH, "utf8").trim();
117
+ if (cachedInstallId)
118
+ return cachedInstallId;
119
+ }
120
+ catch {
121
+ // No file yet -- fall through and create one.
122
+ }
123
+ cachedInstallId = randomUUID();
124
+ try {
125
+ mkdirSync(dirname(INSTALL_ID_PATH), { recursive: true });
126
+ writeFileSync(INSTALL_ID_PATH, cachedInstallId, "utf8");
127
+ }
128
+ catch {
129
+ // Couldn't persist (e.g. read-only home dir) -- still usable for this
130
+ // process's lifetime, just won't be stable across restarts. Telemetry
131
+ // is best-effort by design; this never blocks a tool call.
132
+ }
133
+ return cachedInstallId;
134
+ }
135
+ // One-way hash so a project_id string (which may be a real repo/project
136
+ // name someone doesn't want sent anywhere) never leaves the machine in
137
+ // readable form, while still letting the same project produce the same
138
+ // hash every time -- which is exactly what's needed to count distinct
139
+ // projects per install without ever seeing what those projects are named.
140
+ export function hashProjectId(projectId) {
141
+ return createHash("sha256").update(projectId).digest("hex").slice(0, 16);
142
+ }
143
+ // Classifies a thrown Anthropic API error by status code and the coarse
144
+ // shape of the error body, without ever inspecting or forwarding the body
145
+ // itself. Matches the two failure modes called out in the product brief's
146
+ // Risks section (§06): a key that's out of money, and rate limiting.
147
+ export function classifyApiError(message) {
148
+ const statusMatch = message.match(/Anthropic API error (\d+)/);
149
+ const status = statusMatch ? Number.parseInt(statusMatch[1], 10) : null;
150
+ if (status === 429)
151
+ return { type: "rate_limit", status };
152
+ if (status === 400 && /credit balance|insufficient/i.test(message)) {
153
+ return { type: "insufficient_credit", status };
154
+ }
155
+ return { type: "other", status };
156
+ }
157
+ let client;
158
+ function getClient() {
159
+ if (!TELEMETRY_ENABLED || !POSTHOG_KEY)
160
+ return undefined;
161
+ if (!client) {
162
+ client = new PostHog(POSTHOG_KEY, {
163
+ host: POSTHOG_HOST,
164
+ // Low volume, long-lived process (an MCP server, not a batch job) --
165
+ // flush promptly rather than buffering, so an event isn't silently
166
+ // lost if the server process is killed shortly after a call.
167
+ flushAt: 1,
168
+ flushInterval: 0,
169
+ });
170
+ }
171
+ return client;
172
+ }
173
+ // Fire-and-forget by design: telemetry must never be able to slow down or
174
+ // break a tool call. Every failure path here is swallowed, not surfaced --
175
+ // including "telemetry is disabled," which is the common case.
176
+ function capture(event, properties) {
177
+ const posthog = getClient();
178
+ if (!posthog)
179
+ return;
180
+ try {
181
+ posthog.capture({
182
+ distinctId: installId(),
183
+ event,
184
+ properties: { ...properties, source: "cli" },
185
+ });
186
+ }
187
+ catch {
188
+ // Never let a telemetry failure affect the tool call it's attached to.
189
+ }
190
+ }
191
+ export function captureRecommendation(args) {
192
+ capture("pattern_cli_recommend_component", {
193
+ project_hash: args.projectId ? hashProjectId(args.projectId) : null,
194
+ verdict: args.verdict ?? null,
195
+ confidence: args.confidence ?? null,
196
+ reason: args.reason ?? null,
197
+ ensemble_triggered: args.ensembleTriggered ?? false,
198
+ estimated_cost_usd: args.estimatedCostUsd ?? null,
199
+ served_from_ledger: args.servedFromLedger ?? false,
200
+ });
201
+ }
202
+ export function captureApiError(args) {
203
+ const { type, status } = classifyApiError(args.message);
204
+ capture("pattern_cli_api_error", {
205
+ tool: args.tool,
206
+ error_type: type,
207
+ status_code: status,
208
+ project_hash: args.projectId ? hashProjectId(args.projectId) : null,
209
+ });
210
+ }
211
+ // Best-effort drain on clean shutdown so the last event(s) of a session
212
+ // aren't dropped. Safe to call even when telemetry was never enabled.
213
+ export async function shutdownTelemetry() {
214
+ if (!client)
215
+ return;
216
+ try {
217
+ await client.shutdown();
218
+ }
219
+ catch {
220
+ // Ignore -- process is exiting either way.
221
+ }
222
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pattern-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "MCP tool that judges whether a UI component need should be met with an existing shadcn/ui, 21st.dev, or ReUI component or requires a custom build, using field/requirement coverage scored against real component code.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -36,7 +36,8 @@
36
36
  "prepublishOnly": "npm run build"
37
37
  },
38
38
  "dependencies": {
39
- "@modelcontextprotocol/sdk": "^1.0.0"
39
+ "@modelcontextprotocol/sdk": "^1.0.0",
40
+ "posthog-node": "^5.51.4"
40
41
  },
41
42
  "devDependencies": {
42
43
  "@types/node": "^22.0.0",