@vibekiln/cutline-mcp-cli 0.7.0 → 0.8.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/Dockerfile CHANGED
@@ -3,7 +3,11 @@ FROM node:20-slim AS base
3
3
  WORKDIR /app
4
4
 
5
5
  # Install the CLI globally from npm (includes bundled servers)
6
- RUN npm install -g @vibekiln/cutline-mcp-cli@latest
6
+ # Override at build time for staging package:
7
+ # --build-arg PACKAGE_NAME=@kylewadegrove/cutline-mcp-cli-staging
8
+ ARG PACKAGE_NAME=@vibekiln/cutline-mcp-cli
9
+ ARG PACKAGE_TAG=latest
10
+ RUN npm install -g "${PACKAGE_NAME}@${PACKAGE_TAG}"
7
11
 
8
12
  # Default to the main constraints server (cutline-server.js)
9
13
  # Override with: docker run ... cutline-mcp serve premortem
@@ -1,11 +1,35 @@
1
1
  import chalk from 'chalk';
2
2
  import ora from 'ora';
3
3
  import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
4
+ import { createInterface } from 'node:readline';
4
5
  import { resolve, join } from 'node:path';
5
6
  import { getRefreshToken } from '../auth/keychain.js';
6
7
  import { fetchFirebaseApiKey } from '../utils/config.js';
7
8
  import { saveConfig, loadConfig } from '../utils/config-store.js';
8
9
  const CUTLINE_CONFIG = '.cutline/config.json';
10
+ function prompt(question) {
11
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
12
+ return new Promise((resolvePrompt) => {
13
+ rl.question(question, (answer) => {
14
+ rl.close();
15
+ resolvePrompt(answer.trim());
16
+ });
17
+ });
18
+ }
19
+ async function fetchProducts(idToken, baseUrl) {
20
+ try {
21
+ const res = await fetch(`${baseUrl}/mcpListProducts`, {
22
+ headers: { Authorization: `Bearer ${idToken}` },
23
+ });
24
+ if (!res.ok)
25
+ return { products: [], requestOk: false };
26
+ const data = await res.json();
27
+ return { products: data.products ?? [], requestOk: true };
28
+ }
29
+ catch {
30
+ return { products: [], requestOk: false };
31
+ }
32
+ }
9
33
  async function authenticate(options) {
10
34
  const refreshToken = await getRefreshToken();
11
35
  if (!refreshToken)
@@ -53,6 +77,13 @@ function readCutlineConfig(projectRoot) {
53
77
  return null;
54
78
  }
55
79
  }
80
+ function writeCutlineConfig(projectRoot, config) {
81
+ const cutlineDir = join(projectRoot, '.cutline');
82
+ const configPath = join(cutlineDir, 'config.json');
83
+ mkdirSync(cutlineDir, { recursive: true });
84
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
85
+ return configPath;
86
+ }
56
87
  function cursorRgrRule(config, tier) {
57
88
  const productId = config?.product_id ?? '<from .cutline/config.json>';
58
89
  const productName = config?.product_name ?? 'your product';
@@ -203,7 +234,8 @@ function ensureGitignore(projectRoot, patterns) {
203
234
  }
204
235
  export async function initCommand(options) {
205
236
  const projectRoot = resolve(options.projectRoot ?? process.cwd());
206
- const config = readCutlineConfig(projectRoot);
237
+ let config = readCutlineConfig(projectRoot);
238
+ const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
207
239
  const scanUrl = options.staging
208
240
  ? 'https://cutline-staging.web.app/scan'
209
241
  : 'https://thecutline.ai/scan';
@@ -222,6 +254,79 @@ export async function initCommand(options) {
222
254
  tier = auth.tier;
223
255
  spinner.succeed(chalk.green(`Authenticated as ${auth.email} (${tier})`));
224
256
  }
257
+ if (tier === 'premium' && auth?.idToken && auth.baseUrl) {
258
+ const productsSpinner = ora('Fetching product graphs for normalization...').start();
259
+ const { products, requestOk } = await fetchProducts(auth.idToken, auth.baseUrl);
260
+ productsSpinner.stop();
261
+ if (requestOk && products.length > 0) {
262
+ const currentIndex = config?.product_id
263
+ ? products.findIndex((p) => p.id === config?.product_id)
264
+ : -1;
265
+ let selected;
266
+ if (isInteractive) {
267
+ console.log(chalk.bold('\n Select canonical product for rule generation\n'));
268
+ products.forEach((p, i) => {
269
+ const date = p.createdAt
270
+ ? chalk.dim(` (${new Date(p.createdAt).toLocaleDateString()})`)
271
+ : '';
272
+ const currentTag = currentIndex === i ? chalk.green(' [current]') : '';
273
+ console.log(` ${chalk.cyan(`${i + 1}.`)} ${chalk.white(p.name)}${date}${currentTag}`);
274
+ if (p.brief)
275
+ console.log(` ${chalk.dim(p.brief)}`);
276
+ });
277
+ console.log();
278
+ const defaultHint = currentIndex >= 0
279
+ ? ` (Enter keeps ${products[currentIndex].name})`
280
+ : '';
281
+ const answer = await prompt(chalk.cyan(` Select a product number${defaultHint}: `));
282
+ const choice = parseInt(answer, 10);
283
+ if (!answer && currentIndex >= 0) {
284
+ selected = products[currentIndex];
285
+ }
286
+ else if (Number.isFinite(choice) && choice >= 1 && choice <= products.length) {
287
+ selected = products[choice - 1];
288
+ }
289
+ else if (currentIndex >= 0) {
290
+ selected = products[currentIndex];
291
+ console.log(chalk.yellow(` Invalid selection. Keeping "${selected.name}".`));
292
+ }
293
+ else {
294
+ selected = products[0];
295
+ console.log(chalk.yellow(` Invalid selection. Defaulting to "${selected.name}".`));
296
+ }
297
+ }
298
+ else {
299
+ selected = currentIndex >= 0 ? products[currentIndex] : products[0];
300
+ if (currentIndex < 0) {
301
+ console.log(chalk.dim(` Auto-selected product: ${selected.name}`));
302
+ }
303
+ }
304
+ const changed = selected.id !== config?.product_id || selected.name !== config?.product_name;
305
+ if (changed) {
306
+ const configPath = writeCutlineConfig(projectRoot, {
307
+ product_id: selected.id,
308
+ product_name: selected.name,
309
+ linked_email: auth.email ?? null,
310
+ });
311
+ console.log(chalk.green(` ✓ Normalized product to "${selected.name}"`));
312
+ console.log(chalk.dim(` ${configPath}`));
313
+ }
314
+ config = {
315
+ product_id: selected.id,
316
+ product_name: selected.name,
317
+ linked_email: auth.email ?? config?.linked_email ?? null,
318
+ };
319
+ console.log();
320
+ }
321
+ else if (requestOk) {
322
+ console.log(chalk.yellow(' No completed product graphs found for this premium account.'));
323
+ console.log(chalk.dim(' Generate a deep dive first, then re-run cutline-mcp init for normalization.\n'));
324
+ }
325
+ else {
326
+ console.log(chalk.yellow(' Could not fetch products for normalization (network/auth issue).'));
327
+ console.log(chalk.dim(' Continuing with existing .cutline/config.json values.\n'));
328
+ }
329
+ }
225
330
  if (config) {
226
331
  console.log(chalk.dim(` Product: ${config.product_name ?? config.product_id}`));
227
332
  }
@@ -12,6 +12,18 @@ const SERVER_MAP = {
12
12
  output: 'output-server.js',
13
13
  integrations: 'integrations-server.js',
14
14
  };
15
+ function handleChildMessage(message, pending) {
16
+ const parsed = message;
17
+ const id = normalizeId(parsed?.id);
18
+ if (!id)
19
+ return;
20
+ const entry = pending.get(id);
21
+ if (!entry)
22
+ return;
23
+ clearTimeout(entry.timer);
24
+ pending.delete(id);
25
+ entry.resolve(message);
26
+ }
15
27
  function readJsonBody(req) {
16
28
  return new Promise((resolveBody, rejectBody) => {
17
29
  const chunks = [];
@@ -77,44 +89,68 @@ function serveHttpBridge(serverName, serverPath, opts) {
77
89
  if (!child.stdin || child.killed) {
78
90
  throw new Error('MCP stdio server is not available');
79
91
  }
80
- const body = JSON.stringify(message);
81
- const packet = `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`;
82
- child.stdin.write(packet, 'utf8');
92
+ // Our bundled servers (MCP SDK 1.x) use line-delimited JSON over stdio.
93
+ // Write one JSON-RPC envelope per line.
94
+ const line = `${JSON.stringify(message)}\n`;
95
+ child.stdin.write(line, 'utf8');
83
96
  };
84
97
  child.stdout?.on('data', (chunk) => {
85
98
  stdoutBuffer = Buffer.concat([stdoutBuffer, chunk]);
99
+ // 1) Support MCP framed responses (Content-Length) for compatibility.
86
100
  while (true) {
87
- const separatorIndex = stdoutBuffer.indexOf('\r\n\r\n');
101
+ const crlfSeparator = stdoutBuffer.indexOf('\r\n\r\n');
102
+ const lfSeparator = stdoutBuffer.indexOf('\n\n');
103
+ let separatorIndex = -1;
104
+ let separatorLength = 0;
105
+ if (crlfSeparator >= 0 && (lfSeparator < 0 || crlfSeparator < lfSeparator)) {
106
+ separatorIndex = crlfSeparator;
107
+ separatorLength = 4;
108
+ }
109
+ else if (lfSeparator >= 0) {
110
+ separatorIndex = lfSeparator;
111
+ separatorLength = 2;
112
+ }
88
113
  if (separatorIndex < 0)
89
114
  break;
90
115
  const headers = stdoutBuffer.slice(0, separatorIndex).toString('utf8');
91
116
  const lengthMatch = headers.match(/content-length:\s*(\d+)/i);
92
- if (!lengthMatch) {
93
- stdoutBuffer = stdoutBuffer.slice(separatorIndex + 4);
94
- continue;
95
- }
117
+ if (!lengthMatch)
118
+ break;
96
119
  const contentLength = Number(lengthMatch[1]);
97
- const packetLength = separatorIndex + 4 + contentLength;
120
+ const packetLength = separatorIndex + separatorLength + contentLength;
98
121
  if (stdoutBuffer.length < packetLength)
99
122
  break;
100
- const jsonBytes = stdoutBuffer.slice(separatorIndex + 4, packetLength);
123
+ const jsonBytes = stdoutBuffer.slice(separatorIndex + separatorLength, packetLength);
101
124
  stdoutBuffer = stdoutBuffer.slice(packetLength);
102
125
  try {
103
126
  const message = JSON.parse(jsonBytes.toString('utf8'));
104
- const id = normalizeId(message?.id);
105
- if (!id)
106
- continue;
107
- const entry = pending.get(id);
108
- if (!entry)
109
- continue;
110
- clearTimeout(entry.timer);
111
- pending.delete(id);
112
- entry.resolve(message);
127
+ handleChildMessage(message, pending);
113
128
  }
114
129
  catch {
115
130
  // Ignore malformed child output and keep processing subsequent frames.
116
131
  }
117
132
  }
133
+ // 2) Support line-delimited JSON responses used by our bundled servers.
134
+ const head = stdoutBuffer.slice(0, Math.min(stdoutBuffer.length, 32)).toString('utf8');
135
+ const looksLikeFramedPrefix = /^\s*content-length\s*:/i.test(head);
136
+ if (!looksLikeFramedPrefix) {
137
+ const text = stdoutBuffer.toString('utf8');
138
+ const lines = text.split(/\r?\n/);
139
+ const remainder = lines.pop() ?? '';
140
+ for (const line of lines) {
141
+ const trimmed = line.trim();
142
+ if (!trimmed)
143
+ continue;
144
+ try {
145
+ const message = JSON.parse(trimmed);
146
+ handleChildMessage(message, pending);
147
+ }
148
+ catch {
149
+ // Ignore non-JSON stdout lines (if any).
150
+ }
151
+ }
152
+ stdoutBuffer = Buffer.from(remainder, 'utf8');
153
+ }
118
154
  });
119
155
  child.on('exit', (code, signal) => {
120
156
  failAllPending(new Error(`MCP stdio server exited unexpectedly (${signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`})`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibekiln/cutline-mcp-cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "CLI and MCP servers for Cutline — authenticate, then run constraint-aware MCP servers in Cursor or any MCP client.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",