pattern-mcp 0.12.1 → 0.14.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.
@@ -15,68 +15,9 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
15
  import { homedir } from "node:os";
16
16
  import { dirname, join } from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
- import { createInterface } from "node:readline";
19
18
  import { deriveProjectId } from "./project-id.js";
19
+ import { closeRl, confirm, promptText, shellQuote } from "./prompt.js";
20
20
  const HOOK_MARKER = "pattern-check-gate-hook";
21
- // A queue-based prompt helper, not readline/promises' question() --
22
- // question() only starts listening for a line *after* it's called, but
23
- // with piped/non-TTY stdin (as in an automated test, or `init | cat`)
24
- // every line arrives in one synchronous burst, ahead of any await
25
- // cycle. Confirmed directly: two sequential `rl.question()` calls on a
26
- // piped `printf 'a\nb\n'` answer only the first and hang forever on the
27
- // second -- Node even logs "Detected unsettled top-level await" in that
28
- // repro. The fix is a small always-listening queue: a persistent 'line'
29
- // listener buffers answers that arrive before they're asked for, so
30
- // `askLine` either drains an already-buffered answer immediately or
31
- // waits for the next 'line' event, whichever comes first -- correct for
32
- // both a real interactive TTY (waiter path) and piped/scripted input
33
- // (queue path).
34
- let rl = null;
35
- const lineQueue = [];
36
- const waiters = [];
37
- function ensureRl() {
38
- if (!rl) {
39
- rl = createInterface({ input: process.stdin, output: process.stdout });
40
- rl.on("line", (line) => {
41
- const waiter = waiters.shift();
42
- if (waiter)
43
- waiter(line);
44
- else
45
- lineQueue.push(line);
46
- });
47
- }
48
- return rl;
49
- }
50
- function askLine(promptStr) {
51
- ensureRl();
52
- process.stdout.write(promptStr);
53
- const queued = lineQueue.shift();
54
- if (queued !== undefined)
55
- return Promise.resolve(queued);
56
- return new Promise((resolve) => waiters.push(resolve));
57
- }
58
- function closeRl() {
59
- rl?.close();
60
- rl = null;
61
- }
62
- async function confirm(question, options, defaultYes) {
63
- if (options.yes)
64
- return defaultYes;
65
- const suffix = defaultYes ? "[Y/n]" : "[y/N]";
66
- const answer = (await askLine(`${question} ${suffix} `)).trim().toLowerCase();
67
- if (!answer)
68
- return defaultYes;
69
- return answer === "y" || answer === "yes";
70
- }
71
- async function promptText(question, defaultValue, options) {
72
- if (options.yes)
73
- return defaultValue;
74
- const answer = (await askLine(`${question} [${defaultValue}]: `)).trim();
75
- return answer || defaultValue;
76
- }
77
- function shellQuote(value) {
78
- return `'${value.replace(/'/g, `'\\''`)}'`;
79
- }
80
21
  async function setupClaudeSettings(root, projectIdOverride, options) {
81
22
  const settingsPath = join(root, ".claude", "settings.json");
82
23
  let settings = {};
package/dist/prompt.js ADDED
@@ -0,0 +1,65 @@
1
+ // Shared interactive-prompt plumbing for Pattern's CLI wizards
2
+ // (`pattern-check-gate init`, `pattern-mcp init`). Extracted from
3
+ // init-enforcement.ts so both wizards share one readline instance and one
4
+ // fix for the same bug, instead of drifting apart.
5
+ //
6
+ // A queue-based prompt helper, not readline/promises' question() --
7
+ // question() only starts listening for a line *after* it's called, but
8
+ // with piped/non-TTY stdin (as in an automated test, or `init | cat`)
9
+ // every line arrives in one synchronous burst, ahead of any await
10
+ // cycle. Confirmed directly: two sequential `rl.question()` calls on a
11
+ // piped `printf 'a\nb\n'` answer only the first and hang forever on the
12
+ // second -- Node even logs "Detected unsettled top-level await" in that
13
+ // repro. The fix is a small always-listening queue: a persistent 'line'
14
+ // listener buffers answers that arrive before they're asked for, so
15
+ // `askLine` either drains an already-buffered answer immediately or
16
+ // waits for the next 'line' event, whichever comes first -- correct for
17
+ // both a real interactive TTY (waiter path) and piped/scripted input
18
+ // (queue path).
19
+ import { createInterface } from "node:readline";
20
+ let rl = null;
21
+ const lineQueue = [];
22
+ const waiters = [];
23
+ function ensureRl() {
24
+ if (!rl) {
25
+ rl = createInterface({ input: process.stdin, output: process.stdout });
26
+ rl.on("line", (line) => {
27
+ const waiter = waiters.shift();
28
+ if (waiter)
29
+ waiter(line);
30
+ else
31
+ lineQueue.push(line);
32
+ });
33
+ }
34
+ return rl;
35
+ }
36
+ export function askLine(promptStr) {
37
+ ensureRl();
38
+ process.stdout.write(promptStr);
39
+ const queued = lineQueue.shift();
40
+ if (queued !== undefined)
41
+ return Promise.resolve(queued);
42
+ return new Promise((resolve) => waiters.push(resolve));
43
+ }
44
+ export function closeRl() {
45
+ rl?.close();
46
+ rl = null;
47
+ }
48
+ export async function confirm(question, options, defaultYes) {
49
+ if (options.yes)
50
+ return defaultYes;
51
+ const suffix = defaultYes ? "[Y/n]" : "[y/N]";
52
+ const answer = (await askLine(`${question} ${suffix} `)).trim().toLowerCase();
53
+ if (!answer)
54
+ return defaultYes;
55
+ return answer === "y" || answer === "yes";
56
+ }
57
+ export async function promptText(question, defaultValue, options) {
58
+ if (options.yes)
59
+ return defaultValue;
60
+ const answer = (await askLine(`${question} [${defaultValue}]: `)).trim();
61
+ return answer || defaultValue;
62
+ }
63
+ export function shellQuote(value) {
64
+ return `'${value.replace(/'/g, `'\\''`)}'`;
65
+ }
package/dist/telemetry.js CHANGED
@@ -18,10 +18,12 @@
18
18
  * install ID (see installId() below); a one-way SHA-256 hash of
19
19
  * project_id, truncated to 16 hex chars -- never the raw project_id
20
20
  * string; the verdict shape already written to the local call log
21
- * (verdict, confidence, ensemble_triggered, estimated cost); and, on a
21
+ * (verdict, confidence, ensemble_triggered, estimated cost); on a
22
22
  * failed Anthropic API call, only the HTTP status and a coarse error
23
23
  * classification (rate_limit / insufficient_credit / other) -- never
24
- * the request or response body.
24
+ * the request or response body; and, on every invocation of the
25
+ * binary, a single `pattern_cli_started` event carrying only which
26
+ * mode it ran in (`server` or `init`) -- see captureCliStarted below.
25
27
  * 2. `@posthog/mcp`'s standard MCP instrumentation, wired up in index.ts:
26
28
  * tool name, call duration, error/success, and the same anonymous
27
29
  * install ID as the distinct_id (via its `identify` option), so both
@@ -34,6 +36,15 @@
34
36
  * itself are never sent, by either half. See SECURITY.md and README.md for
35
37
  * the full disclosure and how to opt out.
36
38
  *
39
+ * Manual/ad-hoc test sessions (a one-off MCP client run by hand while
40
+ * debugging, not a checked-in script) should set PATTERN_TELEMETRY=0 before
41
+ * connecting -- there's no way for this file to distinguish that from real
42
+ * usage on its own, and self-testing was previously showing up as if it
43
+ * were adoption (see project_pattern_activation_funnel memory: roughly half
44
+ * of all recorded handshakes turned out to be internal test/smoke-test
45
+ * clients). Checked-in test scripts (e.g. scripts/test-client.mjs) default
46
+ * this off already.
47
+ *
37
48
  * Reuses Pattern's existing PostHog project (the same one the marketing
38
49
  * site sends browser events to) with its public, write-only project key --
39
50
  * safe to embed in a distributed package the same way that key is already
@@ -83,10 +94,12 @@ export function printTelemetryNoticeOnce() {
83
94
  "When on, Pattern sends an anonymous per-install ID, a one-way hash",
84
95
  "of project_id (never the raw string), the same verdict summary",
85
96
  "already written to ~/.pattern/calls.log (verdict, confidence,",
86
- "reason, estimated cost), and standard MCP tool-call analytics (tool",
87
- "name, duration, success/failure) via PostHog's MCP SDK. Tool call",
88
- "arguments and responses are stripped before sending -- component_need,",
89
- "domain, framework, existing_stack, and your API key are never sent.",
97
+ "reason, estimated cost), a single startup event noting whether this",
98
+ "run is the server or the `init` wizard, and standard MCP tool-call",
99
+ "analytics (tool name, duration, success/failure) via PostHog's MCP",
100
+ "SDK. Tool call arguments and responses are stripped before sending",
101
+ "-- component_need, domain, framework, existing_stack, and your API",
102
+ "key are never sent.",
90
103
  "Full field list: https://github.com/donaldrichard19-LVD/pattern-mcp#telemetry",
91
104
  "",
92
105
  "To opt out: PATTERN_TELEMETRY=0",
@@ -216,6 +229,35 @@ export function captureRecommendation(args) {
216
229
  served_from_ledger: args.servedFromLedger ?? false,
217
230
  });
218
231
  }
232
+ // Fires once per process invocation, immediately at startup, before the
233
+ // stdio transport connects and before either first-run notice prints.
234
+ // Distinct from @posthog/mcp's $mcp_initialize (which only fires once a
235
+ // real MCP client completes the JSON-RPC handshake): this fires for
236
+ // every real execution of the binary, including a bare `npx pattern-mcp`
237
+ // run in a terminal that never gets wired into a client, and the `init`
238
+ // subcommand. Exists to measure the gap between "npm registered a
239
+ // download" (includes scanner/mirror traffic that never runs the code at
240
+ // all) and "someone actually ran this" -- see
241
+ // project_pattern_reddit_launch_spike memory for why that gap mattered:
242
+ // a 2026-09-11 download spike showed almost no matching $mcp_initialize
243
+ // growth, and there was no signal at all for the step in between.
244
+ export function captureCliStarted(mode) {
245
+ capture("pattern_cli_started", { mode });
246
+ }
247
+ // Paired with captureCliStarted so a start with no matching handshake is
248
+ // diagnosable instead of silent -- added after a 2026-09-13 incident where
249
+ // 33 starts in one hour produced exactly 1 successful handshake, and
250
+ // telemetry had no way to say why the other 32 processes ended (see
251
+ // project_pattern_activation_funnel memory). Only a coarse reason and the
252
+ // thrown value's constructor name travel -- never the error message or
253
+ // stack, which could contain a file path, a stray argument value, or other
254
+ // local detail never sent by design (see this file's header).
255
+ export function captureCliExited(reason, err) {
256
+ capture("pattern_cli_exited", {
257
+ exit_reason: reason,
258
+ error_name: err instanceof Error ? err.name : null,
259
+ });
260
+ }
219
261
  export function captureApiError(args) {
220
262
  const { type, status } = classifyApiError(args.message);
221
263
  capture("pattern_cli_api_error", {
@@ -225,13 +267,27 @@ export function captureApiError(args) {
225
267
  project_hash: args.projectId ? hashProjectId(args.projectId) : null,
226
268
  });
227
269
  }
270
+ // How long shutdown will wait for a final flush before giving up.
271
+ // Verified directly (not assumed): with an unreachable PostHog host,
272
+ // posthog-node's client.shutdown() does not reject or time out on its
273
+ // own -- it hung well past 8s in a real test (an unroutable/blocked port
274
+ // looks like a stalled TCP connect, not an instant refusal). Without this
275
+ // race, every caller of shutdownTelemetry -- the SIGINT/SIGTERM handlers
276
+ // below AND the `init` wizard's normal exit path -- would hang
277
+ // indefinitely on a restricted network instead of exiting, directly
278
+ // contradicting this function's own "best-effort, never blocks" purpose.
279
+ const SHUTDOWN_TIMEOUT_MS = 2000;
228
280
  // Best-effort drain on clean shutdown so the last event(s) of a session
229
281
  // aren't dropped. Safe to call even when telemetry was never enabled.
282
+ // Always resolves within SHUTDOWN_TIMEOUT_MS regardless of network state.
230
283
  export async function shutdownTelemetry() {
231
284
  if (!client)
232
285
  return;
233
286
  try {
234
- await client.shutdown();
287
+ await Promise.race([
288
+ client.shutdown(),
289
+ new Promise((resolve) => setTimeout(resolve, SHUTDOWN_TIMEOUT_MS).unref()),
290
+ ]);
235
291
  }
236
292
  catch {
237
293
  // Ignore -- process is exiting either way.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pattern-mcp",
3
- "version": "0.12.1",
3
+ "version": "0.14.0",
4
4
  "description": "MCP server that turns your design guidance into a checkable process -- evaluates UI components from external libraries (shadcn/ui, 21st.dev, ReUI) or your own registered design system against a requirements checklist, then tells the agent whether to reuse an existing component or build one from a concrete design reference.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",