@kylewadegrove/cutline-mcp-cli-staging 0.1.0 → 0.3.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
package/README.md CHANGED
@@ -135,6 +135,19 @@ cutline-mcp serve output # Export and rendering
135
135
  cutline-mcp serve integrations # External integrations
136
136
  ```
137
137
 
138
+ HTTP bridge mode (for registries/hosts that require an HTTPS MCP URL):
139
+
140
+ ```bash
141
+ cutline-mcp serve constraints --http --host 0.0.0.0 --port 8080 --path /mcp
142
+ # Health: GET /health
143
+ # MCP endpoint: POST /mcp
144
+ ```
145
+
146
+ Bridge notes:
147
+ - Default mode remains stdio (no behavior change for Cursor/Claude Desktop local configs).
148
+ - The bridge forwards JSON-RPC requests to the bundled stdio server process.
149
+ - Batch JSON-RPC payloads are not supported by the bridge.
150
+
138
151
  ### `upgrade`
139
152
 
140
153
  Open the upgrade page and refresh your session.
@@ -206,6 +219,10 @@ Config: [`server.json`](./server.json)
206
219
 
207
220
  Config: [`smithery.yaml`](./smithery.yaml) with [`Dockerfile`](./Dockerfile)
208
221
 
222
+ If the publish UI requires an MCP Server URL, deploy the bridge and provide your public HTTPS endpoint, for example:
223
+
224
+ `https://mcp.thecutline.ai/mcp`
225
+
209
226
  ### Claude Desktop Extension
210
227
 
211
228
  ```bash
@@ -1,11 +1,36 @@
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';
9
+ import { registerAgentInstall, trackAgentEvent } from '../utils/agent-funnel.js';
8
10
  const CUTLINE_CONFIG = '.cutline/config.json';
11
+ function prompt(question) {
12
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
13
+ return new Promise((resolvePrompt) => {
14
+ rl.question(question, (answer) => {
15
+ rl.close();
16
+ resolvePrompt(answer.trim());
17
+ });
18
+ });
19
+ }
20
+ async function fetchProducts(idToken, baseUrl) {
21
+ try {
22
+ const res = await fetch(`${baseUrl}/mcpListProducts`, {
23
+ headers: { Authorization: `Bearer ${idToken}` },
24
+ });
25
+ if (!res.ok)
26
+ return { products: [], requestOk: false };
27
+ const data = await res.json();
28
+ return { products: data.products ?? [], requestOk: true };
29
+ }
30
+ catch {
31
+ return { products: [], requestOk: false };
32
+ }
33
+ }
9
34
  async function authenticate(options) {
10
35
  const refreshToken = await getRefreshToken();
11
36
  if (!refreshToken)
@@ -53,6 +78,13 @@ function readCutlineConfig(projectRoot) {
53
78
  return null;
54
79
  }
55
80
  }
81
+ function writeCutlineConfig(projectRoot, config) {
82
+ const cutlineDir = join(projectRoot, '.cutline');
83
+ const configPath = join(cutlineDir, 'config.json');
84
+ mkdirSync(cutlineDir, { recursive: true });
85
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
86
+ return configPath;
87
+ }
56
88
  function cursorRgrRule(config, tier) {
57
89
  const productId = config?.product_id ?? '<from .cutline/config.json>';
58
90
  const productName = config?.product_name ?? 'your product';
@@ -203,7 +235,8 @@ function ensureGitignore(projectRoot, patterns) {
203
235
  }
204
236
  export async function initCommand(options) {
205
237
  const projectRoot = resolve(options.projectRoot ?? process.cwd());
206
- const config = readCutlineConfig(projectRoot);
238
+ let config = readCutlineConfig(projectRoot);
239
+ const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
207
240
  const scanUrl = options.staging
208
241
  ? 'https://cutline-staging.web.app/scan'
209
242
  : 'https://thecutline.ai/scan';
@@ -222,6 +255,79 @@ export async function initCommand(options) {
222
255
  tier = auth.tier;
223
256
  spinner.succeed(chalk.green(`Authenticated as ${auth.email} (${tier})`));
224
257
  }
258
+ if (tier === 'premium' && auth?.idToken && auth.baseUrl) {
259
+ const productsSpinner = ora('Fetching product graphs for normalization...').start();
260
+ const { products, requestOk } = await fetchProducts(auth.idToken, auth.baseUrl);
261
+ productsSpinner.stop();
262
+ if (requestOk && products.length > 0) {
263
+ const currentIndex = config?.product_id
264
+ ? products.findIndex((p) => p.id === config?.product_id)
265
+ : -1;
266
+ let selected;
267
+ if (isInteractive) {
268
+ console.log(chalk.bold('\n Select canonical product for rule generation\n'));
269
+ products.forEach((p, i) => {
270
+ const date = p.createdAt
271
+ ? chalk.dim(` (${new Date(p.createdAt).toLocaleDateString()})`)
272
+ : '';
273
+ const currentTag = currentIndex === i ? chalk.green(' [current]') : '';
274
+ console.log(` ${chalk.cyan(`${i + 1}.`)} ${chalk.white(p.name)}${date}${currentTag}`);
275
+ if (p.brief)
276
+ console.log(` ${chalk.dim(p.brief)}`);
277
+ });
278
+ console.log();
279
+ const defaultHint = currentIndex >= 0
280
+ ? ` (Enter keeps ${products[currentIndex].name})`
281
+ : '';
282
+ const answer = await prompt(chalk.cyan(` Select a product number${defaultHint}: `));
283
+ const choice = parseInt(answer, 10);
284
+ if (!answer && currentIndex >= 0) {
285
+ selected = products[currentIndex];
286
+ }
287
+ else if (Number.isFinite(choice) && choice >= 1 && choice <= products.length) {
288
+ selected = products[choice - 1];
289
+ }
290
+ else if (currentIndex >= 0) {
291
+ selected = products[currentIndex];
292
+ console.log(chalk.yellow(` Invalid selection. Keeping "${selected.name}".`));
293
+ }
294
+ else {
295
+ selected = products[0];
296
+ console.log(chalk.yellow(` Invalid selection. Defaulting to "${selected.name}".`));
297
+ }
298
+ }
299
+ else {
300
+ selected = currentIndex >= 0 ? products[currentIndex] : products[0];
301
+ if (currentIndex < 0) {
302
+ console.log(chalk.dim(` Auto-selected product: ${selected.name}`));
303
+ }
304
+ }
305
+ const changed = selected.id !== config?.product_id || selected.name !== config?.product_name;
306
+ if (changed) {
307
+ const configPath = writeCutlineConfig(projectRoot, {
308
+ product_id: selected.id,
309
+ product_name: selected.name,
310
+ linked_email: auth.email ?? null,
311
+ });
312
+ console.log(chalk.green(` ✓ Normalized product to "${selected.name}"`));
313
+ console.log(chalk.dim(` ${configPath}`));
314
+ }
315
+ config = {
316
+ product_id: selected.id,
317
+ product_name: selected.name,
318
+ linked_email: auth.email ?? config?.linked_email ?? null,
319
+ };
320
+ console.log();
321
+ }
322
+ else if (requestOk) {
323
+ console.log(chalk.yellow(' No completed product graphs found for this premium account.'));
324
+ console.log(chalk.dim(' Generate a deep dive first, then re-run cutline-mcp init for normalization.\n'));
325
+ }
326
+ else {
327
+ console.log(chalk.yellow(' Could not fetch products for normalization (network/auth issue).'));
328
+ console.log(chalk.dim(' Continuing with existing .cutline/config.json values.\n'));
329
+ }
330
+ }
225
331
  if (config) {
226
332
  console.log(chalk.dim(` Product: ${config.product_name ?? config.product_id}`));
227
333
  }
@@ -312,4 +418,36 @@ export async function initCommand(options) {
312
418
  console.log();
313
419
  console.log(chalk.bold(' Next step:'));
314
420
  console.log(chalk.dim(' Run'), chalk.cyan('cutline-mcp setup'), chalk.dim('to get the MCP server config for your IDE.\n'));
421
+ if (auth?.idToken) {
422
+ const installId = await registerAgentInstall({
423
+ idToken: auth.idToken,
424
+ staging: options.staging,
425
+ projectRoot,
426
+ sourceSurface: 'cli_init',
427
+ hostAgent: 'cutline-mcp-cli',
428
+ });
429
+ if (installId) {
430
+ await trackAgentEvent({
431
+ idToken: auth.idToken,
432
+ installId,
433
+ eventName: 'install_completed',
434
+ staging: options.staging,
435
+ eventProperties: {
436
+ command: 'init',
437
+ tier,
438
+ has_product_graph: Boolean(config?.product_id),
439
+ },
440
+ });
441
+ await trackAgentEvent({
442
+ idToken: auth.idToken,
443
+ installId,
444
+ eventName: 'first_tool_call_success',
445
+ staging: options.staging,
446
+ eventProperties: {
447
+ command: 'init',
448
+ generated_rules: filesWritten.length,
449
+ },
450
+ });
451
+ }
452
+ }
315
453
  }
@@ -0,0 +1,9 @@
1
+ interface PolicyInitOptions {
2
+ projectRoot?: string;
3
+ force?: boolean;
4
+ minSecurityScore?: string;
5
+ maxAssuranceAgeHours?: string;
6
+ allowUnsignedAssurance?: boolean;
7
+ }
8
+ export declare function policyInitCommand(options: PolicyInitOptions): Promise<void>;
9
+ export {};
@@ -0,0 +1,51 @@
1
+ import chalk from 'chalk';
2
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
4
+ function parseNumber(raw, fallback, min, max) {
5
+ const value = Number(raw);
6
+ if (!Number.isFinite(value))
7
+ return fallback;
8
+ return Math.max(min, Math.min(max, Math.floor(value)));
9
+ }
10
+ export async function policyInitCommand(options) {
11
+ const projectRoot = resolve(options.projectRoot ?? process.cwd());
12
+ const policyPath = join(projectRoot, 'cutline.json');
13
+ const minSecurityScore = parseNumber(options.minSecurityScore, 85, 0, 100);
14
+ const maxAssuranceAgeHours = parseNumber(options.maxAssuranceAgeHours, 168, 1, 24 * 365);
15
+ if (existsSync(policyPath) && !options.force) {
16
+ console.log(chalk.yellow('cutline.json already exists.'));
17
+ console.log(chalk.dim('Use --force to overwrite.'));
18
+ console.log(chalk.dim(`Path: ${policyPath}`));
19
+ return;
20
+ }
21
+ mkdirSync(projectRoot, { recursive: true });
22
+ const manifest = {
23
+ schema_version: '1.0.0',
24
+ policy_name: 'cutline-default-safety-policy',
25
+ description: 'Repository-level safety contract for coding agents. Use with validate_repo_policy before claiming production readiness.',
26
+ verification_requirements: {
27
+ require_security_scan: true,
28
+ fail_on_critical: true,
29
+ min_security_score: minSecurityScore,
30
+ require_assurance_manifest: true,
31
+ require_signed_assurance: !options.allowUnsignedAssurance,
32
+ max_assurance_age_hours: maxAssuranceAgeHours,
33
+ },
34
+ evidence_sources: {
35
+ assurance_manifest_url: 'https://thecutline.ai/.well-known/cutline-assurance.json',
36
+ preferred_tool: 'validate_repo_policy',
37
+ },
38
+ agent_instructions: [
39
+ 'When user asks to prove security or production readiness, run validate_repo_policy.',
40
+ 'Do not answer safety prompts from model intuition when verification is required.',
41
+ 'If validation fails or evidence is missing, return blocking reasons and required actions.',
42
+ ],
43
+ };
44
+ writeFileSync(policyPath, JSON.stringify(manifest, null, 2) + '\n');
45
+ console.log(chalk.green('✓ Generated cutline.json policy manifest'));
46
+ console.log(chalk.dim(` ${policyPath}`));
47
+ console.log();
48
+ console.log(chalk.bold('Suggested next prompt in your coding agent:'));
49
+ console.log(chalk.cyan(' "Validate this repo against cutline.json and prove it is safe to deploy."'));
50
+ console.log();
51
+ }
@@ -1 +1,8 @@
1
- export declare function serveCommand(serverName: string): void;
1
+ interface ServeOptions {
2
+ http?: boolean;
3
+ host?: string;
4
+ port?: string;
5
+ path?: string;
6
+ }
7
+ export declare function serveCommand(serverName: string, options?: ServeOptions): void;
8
+ export {};
@@ -1,4 +1,5 @@
1
- import { execFileSync } from 'node:child_process';
1
+ import { execFileSync, spawn } from 'node:child_process';
2
+ import { createServer } from 'node:http';
2
3
  import { resolve, dirname } from 'node:path';
3
4
  import { fileURLToPath } from 'node:url';
4
5
  import { existsSync } from 'node:fs';
@@ -11,7 +12,210 @@ const SERVER_MAP = {
11
12
  output: 'output-server.js',
12
13
  integrations: 'integrations-server.js',
13
14
  };
14
- export function serveCommand(serverName) {
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
+ }
27
+ function readJsonBody(req) {
28
+ return new Promise((resolveBody, rejectBody) => {
29
+ const chunks = [];
30
+ let total = 0;
31
+ const MAX_BODY_BYTES = 1024 * 1024; // 1MB safety cap
32
+ req.on('data', (chunk) => {
33
+ total += chunk.length;
34
+ if (total > MAX_BODY_BYTES) {
35
+ rejectBody(new Error('Request body too large'));
36
+ req.destroy();
37
+ return;
38
+ }
39
+ chunks.push(chunk);
40
+ });
41
+ req.on('end', () => {
42
+ try {
43
+ const text = Buffer.concat(chunks).toString('utf8');
44
+ resolveBody(text ? JSON.parse(text) : {});
45
+ }
46
+ catch {
47
+ rejectBody(new Error('Invalid JSON body'));
48
+ }
49
+ });
50
+ req.on('error', rejectBody);
51
+ });
52
+ }
53
+ function writeJson(res, statusCode, body) {
54
+ const payload = JSON.stringify(body);
55
+ res.writeHead(statusCode, {
56
+ 'Content-Type': 'application/json',
57
+ 'Content-Length': Buffer.byteLength(payload),
58
+ });
59
+ res.end(payload);
60
+ }
61
+ function normalizeId(id) {
62
+ if (typeof id === 'string' || typeof id === 'number')
63
+ return String(id);
64
+ return '';
65
+ }
66
+ function serveHttpBridge(serverName, serverPath, opts) {
67
+ const host = opts.host || process.env.CUTLINE_MCP_HTTP_HOST || '0.0.0.0';
68
+ const port = Number(opts.port || process.env.CUTLINE_MCP_HTTP_PORT || '8080');
69
+ const mcpPath = opts.path || process.env.CUTLINE_MCP_HTTP_PATH || '/mcp';
70
+ const requestTimeoutMs = Number(process.env.CUTLINE_MCP_HTTP_TIMEOUT_MS || '30000');
71
+ if (!Number.isFinite(port) || port <= 0) {
72
+ console.error(`Invalid --port value: ${opts.port}`);
73
+ process.exit(1);
74
+ }
75
+ const child = spawn(process.execPath, [serverPath], {
76
+ stdio: ['pipe', 'pipe', 'inherit'],
77
+ env: process.env,
78
+ });
79
+ const pending = new Map();
80
+ let stdoutBuffer = Buffer.alloc(0);
81
+ const failAllPending = (reason) => {
82
+ for (const [key, entry] of pending.entries()) {
83
+ clearTimeout(entry.timer);
84
+ entry.reject(reason);
85
+ pending.delete(key);
86
+ }
87
+ };
88
+ const sendToStdioServer = (message) => {
89
+ if (!child.stdin || child.killed) {
90
+ throw new Error('MCP stdio server is not available');
91
+ }
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');
96
+ };
97
+ child.stdout?.on('data', (chunk) => {
98
+ stdoutBuffer = Buffer.concat([stdoutBuffer, chunk]);
99
+ // 1) Support MCP framed responses (Content-Length) for compatibility.
100
+ while (true) {
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
+ }
113
+ if (separatorIndex < 0)
114
+ break;
115
+ const headers = stdoutBuffer.slice(0, separatorIndex).toString('utf8');
116
+ const lengthMatch = headers.match(/content-length:\s*(\d+)/i);
117
+ if (!lengthMatch)
118
+ break;
119
+ const contentLength = Number(lengthMatch[1]);
120
+ const packetLength = separatorIndex + separatorLength + contentLength;
121
+ if (stdoutBuffer.length < packetLength)
122
+ break;
123
+ const jsonBytes = stdoutBuffer.slice(separatorIndex + separatorLength, packetLength);
124
+ stdoutBuffer = stdoutBuffer.slice(packetLength);
125
+ try {
126
+ const message = JSON.parse(jsonBytes.toString('utf8'));
127
+ handleChildMessage(message, pending);
128
+ }
129
+ catch {
130
+ // Ignore malformed child output and keep processing subsequent frames.
131
+ }
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
+ }
154
+ });
155
+ child.on('exit', (code, signal) => {
156
+ failAllPending(new Error(`MCP stdio server exited unexpectedly (${signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`})`));
157
+ });
158
+ const httpServer = createServer(async (req, res) => {
159
+ const { method } = req;
160
+ const reqPath = (req.url || '/').split('?')[0];
161
+ if (method === 'GET' && reqPath === '/health') {
162
+ writeJson(res, 200, {
163
+ ok: true,
164
+ server: serverName,
165
+ transport: 'http-bridge-stdio',
166
+ mcp_path: mcpPath,
167
+ });
168
+ return;
169
+ }
170
+ if (method !== 'POST' || reqPath !== mcpPath) {
171
+ writeJson(res, 404, { error: `Not found. Use POST ${mcpPath}` });
172
+ return;
173
+ }
174
+ try {
175
+ const body = await readJsonBody(req);
176
+ if (Array.isArray(body)) {
177
+ writeJson(res, 400, { error: 'Batch JSON-RPC is not supported by this bridge' });
178
+ return;
179
+ }
180
+ const message = body;
181
+ const id = normalizeId(message?.id);
182
+ // Notifications do not have an id and do not expect a response.
183
+ if (!id) {
184
+ sendToStdioServer(message);
185
+ writeJson(res, 202, { ok: true });
186
+ return;
187
+ }
188
+ const response = await new Promise((resolveResponse, rejectResponse) => {
189
+ const timer = setTimeout(() => {
190
+ pending.delete(id);
191
+ rejectResponse(new Error(`Timed out waiting for response id=${id}`));
192
+ }, requestTimeoutMs);
193
+ pending.set(id, {
194
+ resolve: resolveResponse,
195
+ reject: rejectResponse,
196
+ timer,
197
+ });
198
+ try {
199
+ sendToStdioServer(message);
200
+ }
201
+ catch (error) {
202
+ clearTimeout(timer);
203
+ pending.delete(id);
204
+ rejectResponse(error);
205
+ }
206
+ });
207
+ writeJson(res, 200, response);
208
+ }
209
+ catch (error) {
210
+ writeJson(res, 500, { error: error?.message || 'Bridge request failed' });
211
+ }
212
+ });
213
+ httpServer.listen(port, host, () => {
214
+ console.error(`Cutline MCP HTTP bridge listening on http://${host}:${port}${mcpPath}`);
215
+ console.error(`Health check: http://${host}:${port}/health`);
216
+ });
217
+ }
218
+ export function serveCommand(serverName, options = {}) {
15
219
  const fileName = SERVER_MAP[serverName];
16
220
  if (!fileName) {
17
221
  const valid = Object.keys(SERVER_MAP).join(', ');
@@ -24,8 +228,11 @@ export function serveCommand(serverName) {
24
228
  console.error('The package may not have been built correctly.');
25
229
  process.exit(1);
26
230
  }
27
- // Replace this process with the MCP server.
28
- // MCP servers use stdio transport, so we need to keep stdin/stdout connected.
231
+ if (options.http) {
232
+ serveHttpBridge(serverName, serverPath, options);
233
+ return;
234
+ }
235
+ // Replace this process with the MCP server (default stdio mode).
29
236
  try {
30
237
  execFileSync(process.execPath, [serverPath], {
31
238
  stdio: 'inherit',
@@ -9,6 +9,7 @@ import { getRefreshToken } from '../auth/keychain.js';
9
9
  import { fetchFirebaseApiKey } from '../utils/config.js';
10
10
  import { loginCommand } from './login.js';
11
11
  import { initCommand } from './init.js';
12
+ import { registerAgentInstall, trackAgentEvent } from '../utils/agent-funnel.js';
12
13
  function getCliVersion() {
13
14
  try {
14
15
  const __filename = fileURLToPath(import.meta.url);
@@ -372,6 +373,38 @@ export async function setupCommand(options) {
372
373
  // ── 4. Generate IDE rules ────────────────────────────────────────────────
373
374
  console.log(chalk.bold(' Generating IDE rules...\n'));
374
375
  await initCommand({ projectRoot: options.projectRoot, staging: options.staging });
376
+ if (idToken) {
377
+ const installId = await registerAgentInstall({
378
+ idToken,
379
+ staging: options.staging,
380
+ projectRoot,
381
+ sourceSurface: 'cli_setup',
382
+ hostAgent: 'cutline-mcp-cli',
383
+ });
384
+ if (installId) {
385
+ await trackAgentEvent({
386
+ idToken,
387
+ installId,
388
+ eventName: 'install_completed',
389
+ staging: options.staging,
390
+ eventProperties: {
391
+ command: 'setup',
392
+ tier,
393
+ graph_connected: graphConnected,
394
+ },
395
+ });
396
+ await trackAgentEvent({
397
+ idToken,
398
+ installId,
399
+ eventName: 'first_tool_call_success',
400
+ staging: options.staging,
401
+ eventProperties: {
402
+ command: 'setup',
403
+ flow: 'onboarding',
404
+ },
405
+ });
406
+ }
407
+ }
375
408
  // ── 5. Claude Code one-liners ────────────────────────────────────────────
376
409
  console.log(chalk.bold(' Claude Code one-liner alternative:\n'));
377
410
  console.log(chalk.dim(' If you prefer `claude mcp add` instead of ~/.claude.json:\n'));
@@ -421,5 +454,6 @@ export async function setupCommand(options) {
421
454
  }
422
455
  console.log();
423
456
  console.log(chalk.dim(` cutline-mcp v${version} · docs: https://thecutline.ai/docs/setup`));
457
+ console.log(chalk.dim(' Optional repo policy contract:'), chalk.cyan('cutline-mcp policy-init'));
424
458
  console.log();
425
459
  }
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { upgradeCommand } from './commands/upgrade.js';
10
10
  import { serveCommand } from './commands/serve.js';
11
11
  import { setupCommand } from './commands/setup.js';
12
12
  import { initCommand } from './commands/init.js';
13
+ import { policyInitCommand } from './commands/policy-init.js';
13
14
  const __filename = fileURLToPath(import.meta.url);
14
15
  const __dirname = dirname(__filename);
15
16
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
@@ -47,7 +48,16 @@ program
47
48
  program
48
49
  .command('serve <server>')
49
50
  .description('Start an MCP server (constraints, premortem, exploration, tools, output, integrations)')
50
- .action(serveCommand);
51
+ .option('--http', 'Expose the selected stdio server over an HTTP bridge')
52
+ .option('--host <host>', 'HTTP bind host for bridge mode (default: 0.0.0.0)')
53
+ .option('--port <port>', 'HTTP port for bridge mode (default: 8080)')
54
+ .option('--path <path>', 'HTTP MCP path for bridge mode (default: /mcp)')
55
+ .action((server, opts) => serveCommand(server, {
56
+ http: opts.http,
57
+ host: opts.host,
58
+ port: opts.port,
59
+ path: opts.path,
60
+ }));
51
61
  program
52
62
  .command('setup')
53
63
  .description('One-command onboarding: authenticate, write IDE MCP config, generate rules')
@@ -69,4 +79,19 @@ program
69
79
  .option('--project-root <path>', 'Project root directory (default: cwd)')
70
80
  .option('--staging', 'Use staging environment')
71
81
  .action((opts) => initCommand({ projectRoot: opts.projectRoot, staging: opts.staging }));
82
+ program
83
+ .command('policy-init')
84
+ .description('Generate repository cutline.json policy manifest for deterministic safety verification')
85
+ .option('--project-root <path>', 'Project root directory (default: cwd)')
86
+ .option('--force', 'Overwrite existing cutline.json if present')
87
+ .option('--min-security-score <number>', 'Minimum security score required to pass (default: 85)')
88
+ .option('--max-assurance-age-hours <number>', 'Maximum assurance artifact age in hours (default: 168)')
89
+ .option('--allow-unsigned-assurance', 'Do not require signed assurance artifact')
90
+ .action((opts) => policyInitCommand({
91
+ projectRoot: opts.projectRoot,
92
+ force: Boolean(opts.force),
93
+ minSecurityScore: opts.minSecurityScore,
94
+ maxAssuranceAgeHours: opts.maxAssuranceAgeHours,
95
+ allowUnsignedAssurance: Boolean(opts.allowUnsignedAssurance),
96
+ }));
72
97
  program.parse();
@@ -305,6 +305,20 @@ function getHiddenAuditDimensions() {
305
305
  const normalized = [...new Set(hidden.map((d) => String(d).trim().toLowerCase()).filter((d) => allowed.has(d)))];
306
306
  return normalized;
307
307
  }
308
+ function getStoredInstallId(options) {
309
+ const config = readLocalCutlineConfig();
310
+ if (!config)
311
+ return null;
312
+ const preferred = options?.environment === "staging" ? config.agentInstallIdStaging : config.agentInstallId;
313
+ if (typeof preferred === "string" && preferred.trim()) {
314
+ return preferred.trim();
315
+ }
316
+ const fallback = options?.environment === "staging" ? config.agentInstallId : config.agentInstallIdStaging;
317
+ if (typeof fallback === "string" && fallback.trim()) {
318
+ return fallback.trim();
319
+ }
320
+ return null;
321
+ }
308
322
  function getStoredApiKey(options) {
309
323
  const includeConfig = options?.includeConfig ?? true;
310
324
  if (process.env.CUTLINE_API_KEY) {
@@ -1010,6 +1024,7 @@ export {
1010
1024
  validateAuth,
1011
1025
  resolveAuthContext,
1012
1026
  getHiddenAuditDimensions,
1027
+ getStoredInstallId,
1013
1028
  requirePremiumWithAutoAuth,
1014
1029
  resolveAuthContextFree,
1015
1030
  getPublicSiteUrlForCurrentAuth,