@vibekiln/cutline-mcp-cli 0.7.0 → 0.8.1
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 +5 -1
- package/dist/commands/init.js +106 -1
- package/dist/commands/serve.js +55 -19
- package/dist/servers/cutline-server.js +77 -10
- package/package.json +1 -1
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
|
-
|
|
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
|
package/dist/commands/init.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/commands/serve.js
CHANGED
|
@@ -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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
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
|
-
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
117
|
+
if (!lengthMatch)
|
|
118
|
+
break;
|
|
96
119
|
const contentLength = Number(lengthMatch[1]);
|
|
97
|
-
const packetLength = separatorIndex +
|
|
120
|
+
const packetLength = separatorIndex + separatorLength + contentLength;
|
|
98
121
|
if (stdoutBuffer.length < packetLength)
|
|
99
122
|
break;
|
|
100
|
-
const jsonBytes = stdoutBuffer.slice(separatorIndex +
|
|
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
|
-
|
|
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'}`})`));
|
|
@@ -2291,6 +2291,19 @@ function healBindings(entities, bindings, filePaths) {
|
|
|
2291
2291
|
total_entities: entities.length
|
|
2292
2292
|
};
|
|
2293
2293
|
}
|
|
2294
|
+
function findBoundEntities(filePath, bindings) {
|
|
2295
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
2296
|
+
const matched = /* @__PURE__ */ new Set();
|
|
2297
|
+
for (const binding of bindings) {
|
|
2298
|
+
for (const pattern of binding.file_patterns) {
|
|
2299
|
+
if (globMatch(normalized, pattern)) {
|
|
2300
|
+
matched.add(binding.entity_id);
|
|
2301
|
+
break;
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
return [...matched];
|
|
2306
|
+
}
|
|
2294
2307
|
function tokenize(name2) {
|
|
2295
2308
|
return name2.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((t) => t.length > 2);
|
|
2296
2309
|
}
|
|
@@ -7489,6 +7502,59 @@ function inferEntityNameFromTask(task) {
|
|
|
7489
7502
|
function normalizeName(value) {
|
|
7490
7503
|
return value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
|
|
7491
7504
|
}
|
|
7505
|
+
function canonicalizeBindings(bindings, entities) {
|
|
7506
|
+
const entityTypes = new Map(entities.map((e) => [e.id, e.type]));
|
|
7507
|
+
const normalized = [];
|
|
7508
|
+
for (const raw of bindings) {
|
|
7509
|
+
const legacyRaw = raw;
|
|
7510
|
+
const entityId = typeof raw.entity_id === "string" ? raw.entity_id : typeof legacyRaw.entityId === "string" ? legacyRaw.entityId : void 0;
|
|
7511
|
+
if (!entityId || !entityTypes.has(entityId))
|
|
7512
|
+
continue;
|
|
7513
|
+
if (!Array.isArray(raw.file_patterns) || raw.file_patterns.length === 0)
|
|
7514
|
+
continue;
|
|
7515
|
+
const filePatterns = raw.file_patterns.filter((v) => typeof v === "string");
|
|
7516
|
+
if (filePatterns.length === 0)
|
|
7517
|
+
continue;
|
|
7518
|
+
normalized.push({
|
|
7519
|
+
id: typeof raw.id === "string" ? raw.id : `bind:${entityId}`,
|
|
7520
|
+
entity_id: entityId,
|
|
7521
|
+
entity_type: typeof raw.entity_type === "string" ? raw.entity_type : entityTypes.get(entityId),
|
|
7522
|
+
file_patterns: filePatterns,
|
|
7523
|
+
confidence: typeof raw.confidence === "number" ? raw.confidence : 0.5,
|
|
7524
|
+
manual: Boolean(raw.manual)
|
|
7525
|
+
});
|
|
7526
|
+
}
|
|
7527
|
+
return normalized;
|
|
7528
|
+
}
|
|
7529
|
+
function rankEntitiesForFilePath(filePath, entities) {
|
|
7530
|
+
const normalizedPath = filePath.toLowerCase().replace(/\\/g, "/");
|
|
7531
|
+
const fileStem = normalizedPath.split("/").pop()?.replace(/\.[^.]+$/, "") ?? "";
|
|
7532
|
+
const pathTokens = new Set(normalizedPath.replace(/\.[^.]+$/, "").split(/[\/._-]/).filter((t) => t.length >= 3));
|
|
7533
|
+
const scored = entities.map((entity) => {
|
|
7534
|
+
const labels = [entity.name, ...entity.aliases].filter(Boolean);
|
|
7535
|
+
if (labels.length === 0)
|
|
7536
|
+
return { entity, score: 0 };
|
|
7537
|
+
let score = 0;
|
|
7538
|
+
for (const label of labels) {
|
|
7539
|
+
const labelNorm = normalizeName(label);
|
|
7540
|
+
const labelTokens = labelNorm.split("_").filter((t) => t.length >= 3);
|
|
7541
|
+
if (labelTokens.length === 0)
|
|
7542
|
+
continue;
|
|
7543
|
+
const matchedTokens = labelTokens.filter((t) => pathTokens.has(t)).length;
|
|
7544
|
+
if (matchedTokens > 0) {
|
|
7545
|
+
score = Math.max(score, matchedTokens / labelTokens.length);
|
|
7546
|
+
}
|
|
7547
|
+
if (normalizedPath.includes(labelNorm.replace(/_/g, "/"))) {
|
|
7548
|
+
score = Math.max(score, 0.9);
|
|
7549
|
+
}
|
|
7550
|
+
if (fileStem === labelNorm || fileStem.includes(labelNorm) || labelNorm.includes(fileStem)) {
|
|
7551
|
+
score = Math.max(score, 0.8);
|
|
7552
|
+
}
|
|
7553
|
+
}
|
|
7554
|
+
return { entity, score };
|
|
7555
|
+
}).filter((item) => item.score >= 0.45).sort((a, b) => b.score - a.score);
|
|
7556
|
+
return scored.map((item) => item.entity);
|
|
7557
|
+
}
|
|
7492
7558
|
async function seedScopeEntityFromAuto(params) {
|
|
7493
7559
|
const { product_id, name: name2, entity_type, description, parent_id, tags, similarity_threshold } = params;
|
|
7494
7560
|
const slug = normalizeName(name2).slice(0, 40);
|
|
@@ -9557,7 +9623,6 @@ ${recommendation}`;
|
|
|
9557
9623
|
used_category_prefilter: usePreFilter,
|
|
9558
9624
|
phase: autoPhase,
|
|
9559
9625
|
rgr_plan: autoRgrPlan || void 0,
|
|
9560
|
-
requested_outcome: requestedOutcome,
|
|
9561
9626
|
scope_expansion: scopeExpansion,
|
|
9562
9627
|
scope_seeded: scopeSeedResult || void 0,
|
|
9563
9628
|
requested_outcome: requestedOutcome
|
|
@@ -10414,20 +10479,22 @@ ${JSON.stringify(metrics, null, 2)}` }
|
|
|
10414
10479
|
}]
|
|
10415
10480
|
};
|
|
10416
10481
|
}
|
|
10417
|
-
const
|
|
10482
|
+
const normalizedBindings = canonicalizeBindings(rgrBindings, rgrEntities);
|
|
10483
|
+
const rgrTraverser = new GraphTraverser(rgrEntities, rgrEdges, rgrConstraints, normalizedBindings);
|
|
10418
10484
|
const rgrMatched = rgrTraverser.findEntitiesByFilePath(file_path);
|
|
10419
10485
|
if (rgrMatched.length === 0) {
|
|
10420
|
-
const
|
|
10421
|
-
for (const
|
|
10422
|
-
|
|
10423
|
-
|
|
10424
|
-
|
|
10425
|
-
rgrMatched.push(found);
|
|
10426
|
-
break;
|
|
10427
|
-
}
|
|
10486
|
+
const reverseMatchedIds = findBoundEntities(file_path, normalizedBindings);
|
|
10487
|
+
for (const id of reverseMatchedIds) {
|
|
10488
|
+
const found = rgrEntities.find((entity) => entity.id === id);
|
|
10489
|
+
if (found) {
|
|
10490
|
+
rgrMatched.push(found);
|
|
10428
10491
|
}
|
|
10429
10492
|
}
|
|
10430
10493
|
}
|
|
10494
|
+
if (rgrMatched.length === 0) {
|
|
10495
|
+
const rankedFallback = rankEntitiesForFilePath(file_path, rgrEntities).slice(0, 2);
|
|
10496
|
+
rgrMatched.push(...rankedFallback);
|
|
10497
|
+
}
|
|
10431
10498
|
if (rgrMatched.length === 0) {
|
|
10432
10499
|
const governance2 = buildGovernanceEnvelope({
|
|
10433
10500
|
decision: "revise",
|
package/package.json
CHANGED