@kylewadegrove/cutline-mcp-cli-staging 0.1.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.
Files changed (43) hide show
  1. package/Dockerfile +11 -0
  2. package/README.md +243 -0
  3. package/dist/auth/callback.d.ts +6 -0
  4. package/dist/auth/callback.js +97 -0
  5. package/dist/auth/keychain.d.ts +3 -0
  6. package/dist/auth/keychain.js +15 -0
  7. package/dist/commands/init.d.ts +4 -0
  8. package/dist/commands/init.js +315 -0
  9. package/dist/commands/login.d.ts +7 -0
  10. package/dist/commands/login.js +166 -0
  11. package/dist/commands/logout.d.ts +1 -0
  12. package/dist/commands/logout.js +25 -0
  13. package/dist/commands/serve.d.ts +1 -0
  14. package/dist/commands/serve.js +38 -0
  15. package/dist/commands/setup.d.ts +7 -0
  16. package/dist/commands/setup.js +425 -0
  17. package/dist/commands/status.d.ts +3 -0
  18. package/dist/commands/status.js +127 -0
  19. package/dist/commands/upgrade.d.ts +3 -0
  20. package/dist/commands/upgrade.js +112 -0
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.js +72 -0
  23. package/dist/servers/chunk-DE7R7WKY.js +331 -0
  24. package/dist/servers/chunk-IDSVMCGM.js +948 -0
  25. package/dist/servers/chunk-KMUSQOTJ.js +47 -0
  26. package/dist/servers/chunk-OP4EO6FV.js +454 -0
  27. package/dist/servers/chunk-X2B5QUWO.js +1094 -0
  28. package/dist/servers/cutline-server.js +11199 -0
  29. package/dist/servers/data-client-AQ5DGSAR.js +162 -0
  30. package/dist/servers/exploration-server.js +930 -0
  31. package/dist/servers/graph-metrics-KLHCMDFT.js +12 -0
  32. package/dist/servers/integrations-server.js +107 -0
  33. package/dist/servers/output-server.js +107 -0
  34. package/dist/servers/premortem-server.js +971 -0
  35. package/dist/servers/tools-server.js +287 -0
  36. package/dist/utils/config-store.d.ts +8 -0
  37. package/dist/utils/config-store.js +42 -0
  38. package/dist/utils/config.d.ts +22 -0
  39. package/dist/utils/config.js +48 -0
  40. package/mcpb/manifest.json +77 -0
  41. package/package.json +76 -0
  42. package/server.json +42 -0
  43. package/smithery.yaml +10 -0
@@ -0,0 +1,425 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import { createInterface } from 'node:readline';
4
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { join, dirname, resolve } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { getRefreshToken } from '../auth/keychain.js';
9
+ import { fetchFirebaseApiKey } from '../utils/config.js';
10
+ import { loginCommand } from './login.js';
11
+ import { initCommand } from './init.js';
12
+ function getCliVersion() {
13
+ try {
14
+ const __filename = fileURLToPath(import.meta.url);
15
+ const pkg = JSON.parse(readFileSync(join(dirname(__filename), '..', '..', 'package.json'), 'utf-8'));
16
+ return pkg.version ?? 'unknown';
17
+ }
18
+ catch {
19
+ return 'unknown';
20
+ }
21
+ }
22
+ const SERVER_NAMES = [
23
+ 'constraints',
24
+ 'premortem',
25
+ 'exploration',
26
+ 'tools',
27
+ 'output',
28
+ 'integrations',
29
+ ];
30
+ const AUDIT_DIMENSIONS = ['engineering', 'security', 'reliability', 'scalability', 'compliance'];
31
+ async function detectTier(options) {
32
+ const refreshToken = await getRefreshToken();
33
+ if (!refreshToken)
34
+ return { tier: 'free' };
35
+ try {
36
+ const apiKey = await fetchFirebaseApiKey(options);
37
+ const response = await fetch(`https://securetoken.googleapis.com/v1/token?key=${apiKey}`, {
38
+ method: 'POST',
39
+ headers: { 'Content-Type': 'application/json' },
40
+ body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: refreshToken }),
41
+ });
42
+ if (!response.ok)
43
+ return { tier: 'free' };
44
+ const data = await response.json();
45
+ const idToken = data.id_token;
46
+ const payload = JSON.parse(Buffer.from(idToken.split('.')[1], 'base64').toString());
47
+ const baseUrl = options.staging
48
+ ? 'https://us-central1-cutline-staging.cloudfunctions.net'
49
+ : 'https://us-central1-cutline-prod.cloudfunctions.net';
50
+ const subRes = await fetch(`${baseUrl}/mcpSubscriptionStatus`, {
51
+ headers: { Authorization: `Bearer ${idToken}` },
52
+ });
53
+ const sub = subRes.ok ? await subRes.json() : { status: 'free' };
54
+ const isPremium = sub.status === 'active' || sub.status === 'trialing';
55
+ return { tier: isPremium ? 'premium' : 'free', email: payload.email, idToken };
56
+ }
57
+ catch {
58
+ return { tier: 'free' };
59
+ }
60
+ }
61
+ async function fetchProducts(idToken, options) {
62
+ try {
63
+ const baseUrl = options.staging
64
+ ? 'https://us-central1-cutline-staging.cloudfunctions.net'
65
+ : 'https://us-central1-cutline-prod.cloudfunctions.net';
66
+ const res = await fetch(`${baseUrl}/mcpListProducts`, {
67
+ headers: { Authorization: `Bearer ${idToken}` },
68
+ });
69
+ if (!res.ok)
70
+ return { products: [], requestOk: false };
71
+ const data = await res.json();
72
+ return { products: data.products ?? [], requestOk: true };
73
+ }
74
+ catch {
75
+ return { products: [], requestOk: false };
76
+ }
77
+ }
78
+ function prompt(question) {
79
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
80
+ return new Promise((resolve) => {
81
+ rl.question(question, (answer) => {
82
+ rl.close();
83
+ resolve(answer.trim());
84
+ });
85
+ });
86
+ }
87
+ function parseDimensionCsv(csv) {
88
+ if (!csv)
89
+ return [];
90
+ return csv
91
+ .split(',')
92
+ .map((s) => s.trim())
93
+ .filter(Boolean);
94
+ }
95
+ function normalizeAuditDimensions(values) {
96
+ const allowed = new Set(AUDIT_DIMENSIONS);
97
+ const deduped = [...new Set(values.map((v) => v.trim().toLowerCase()).filter(Boolean))];
98
+ const valid = deduped.filter((v) => allowed.has(v));
99
+ const invalid = deduped.filter((v) => !allowed.has(v));
100
+ return { valid, invalid };
101
+ }
102
+ async function resolveHiddenAuditDimensions(options) {
103
+ const explicit = [
104
+ ...(options.hideAuditDimension ?? []),
105
+ ...parseDimensionCsv(options.hideAuditDimensions),
106
+ ];
107
+ if (explicit.length > 0) {
108
+ const { valid, invalid } = normalizeAuditDimensions(explicit);
109
+ if (invalid.length > 0) {
110
+ console.log(chalk.yellow(` Ignoring invalid audit dimensions: ${invalid.join(', ')} ` +
111
+ `(allowed: ${AUDIT_DIMENSIONS.join(', ')})`));
112
+ }
113
+ return valid;
114
+ }
115
+ const answer = await prompt(chalk.cyan(` Hide any code audit dimensions? ` +
116
+ `(comma-separated: ${AUDIT_DIMENSIONS.join(', ')}; Enter for none): `));
117
+ if (!answer)
118
+ return [];
119
+ const { valid, invalid } = normalizeAuditDimensions(parseDimensionCsv(answer));
120
+ if (invalid.length > 0) {
121
+ console.log(chalk.yellow(` Ignoring invalid audit dimensions: ${invalid.join(', ')} ` +
122
+ `(allowed: ${AUDIT_DIMENSIONS.join(', ')})`));
123
+ }
124
+ return valid;
125
+ }
126
+ function writeAuditVisibilityConfig(hiddenDimensions) {
127
+ const configDir = join(homedir(), '.cutline-mcp');
128
+ const configPath = join(configDir, 'config.json');
129
+ let existing = {};
130
+ try {
131
+ if (existsSync(configPath)) {
132
+ existing = JSON.parse(readFileSync(configPath, 'utf-8'));
133
+ }
134
+ }
135
+ catch {
136
+ existing = {};
137
+ }
138
+ const next = {
139
+ ...existing,
140
+ audit_visibility: {
141
+ ...(existing.audit_visibility ?? {}),
142
+ hidden_dimensions: hiddenDimensions,
143
+ },
144
+ };
145
+ mkdirSync(configDir, { recursive: true });
146
+ writeFileSync(configPath, JSON.stringify(next, null, 2) + '\n');
147
+ }
148
+ function resolveServeRuntime() {
149
+ // Optional explicit override for advanced environments.
150
+ if (process.env.CUTLINE_MCP_BIN?.trim()) {
151
+ return {
152
+ command: process.env.CUTLINE_MCP_BIN.trim(),
153
+ argsPrefix: [],
154
+ source: 'binary',
155
+ };
156
+ }
157
+ // Prefer the globally installed binary for faster/more reliable startup.
158
+ const voltaBin = join(homedir(), '.volta', 'bin', 'cutline-mcp');
159
+ if (existsSync(voltaBin)) {
160
+ return {
161
+ command: voltaBin,
162
+ argsPrefix: [],
163
+ source: 'binary',
164
+ };
165
+ }
166
+ // Fallback for machines without a global install.
167
+ const voltaNpx = join(homedir(), '.volta', 'bin', 'npx');
168
+ const npxCommand = existsSync(voltaNpx) ? voltaNpx : 'npx';
169
+ return {
170
+ command: npxCommand,
171
+ argsPrefix: ['-y', '@vibekiln/cutline-mcp-cli@latest'],
172
+ source: 'npx',
173
+ };
174
+ }
175
+ function buildServerConfig(runtime) {
176
+ const config = {};
177
+ for (const name of SERVER_NAMES) {
178
+ config[`cutline-${name}`] = {
179
+ command: runtime.command,
180
+ args: [...runtime.argsPrefix, 'serve', name],
181
+ };
182
+ }
183
+ return config;
184
+ }
185
+ function mergeIdeConfig(filePath, serverConfig) {
186
+ let existing = {};
187
+ if (existsSync(filePath)) {
188
+ try {
189
+ existing = JSON.parse(readFileSync(filePath, 'utf-8'));
190
+ }
191
+ catch {
192
+ existing = {};
193
+ }
194
+ }
195
+ else {
196
+ const dir = join(filePath, '..');
197
+ mkdirSync(dir, { recursive: true });
198
+ }
199
+ const existingServers = (existing.mcpServers ?? {});
200
+ // Remove old cutline-* entries, then add fresh ones
201
+ const cleaned = {};
202
+ for (const [key, val] of Object.entries(existingServers)) {
203
+ if (!key.startsWith('cutline-')) {
204
+ cleaned[key] = val;
205
+ }
206
+ }
207
+ existing.mcpServers = { ...cleaned, ...serverConfig };
208
+ writeFileSync(filePath, JSON.stringify(existing, null, 2) + '\n');
209
+ return true;
210
+ }
211
+ export async function setupCommand(options) {
212
+ const version = getCliVersion();
213
+ console.log(chalk.bold(`\n🔌 Cutline MCP Setup`) + chalk.dim(` v${version}\n`));
214
+ // ── 1. Authenticate ──────────────────────────────────────────────────────
215
+ const hasToken = await getRefreshToken();
216
+ if (!hasToken && !options.skipLogin) {
217
+ console.log(chalk.dim(' No credentials found — starting login flow.\n'));
218
+ await loginCommand({ staging: options.staging, source: 'setup' });
219
+ console.log();
220
+ const tokenAfterLogin = await getRefreshToken();
221
+ if (!tokenAfterLogin) {
222
+ console.log(chalk.yellow(' Login not completed — trying account creation instead.\n'));
223
+ await loginCommand({ staging: options.staging, signup: true, source: 'setup' });
224
+ console.log();
225
+ }
226
+ }
227
+ const spinner = ora('Detecting account tier...').start();
228
+ const { tier, email, idToken } = await detectTier({ staging: options.staging });
229
+ if (email) {
230
+ spinner.succeed(chalk.green(`Authenticated as ${email} (${tier})`));
231
+ }
232
+ else {
233
+ spinner.succeed(chalk.yellow(`Running as ${tier} tier`));
234
+ }
235
+ console.log();
236
+ // ── 2. Connect to a product graph ────────────────────────────────────────
237
+ const projectRoot = resolve(options.projectRoot ?? process.cwd());
238
+ const configPath = join(projectRoot, '.cutline', 'config.json');
239
+ const hasExistingConfig = existsSync(configPath);
240
+ let existingConfig = null;
241
+ let hasUsableExistingConfig = false;
242
+ let graphConnected = false;
243
+ if (hasExistingConfig) {
244
+ try {
245
+ existingConfig = JSON.parse(readFileSync(configPath, 'utf-8'));
246
+ hasUsableExistingConfig = Boolean(existingConfig?.product_id);
247
+ graphConnected = hasUsableExistingConfig;
248
+ }
249
+ catch {
250
+ existingConfig = null;
251
+ hasUsableExistingConfig = false;
252
+ graphConnected = false;
253
+ }
254
+ }
255
+ // Validate that an existing binding is visible to the current account.
256
+ // This prevents showing "connected" when the repo has a product_id from
257
+ // a different account.
258
+ if (hasUsableExistingConfig && idToken) {
259
+ const currentEmail = (email ?? '').trim().toLowerCase();
260
+ const boundEmail = String(existingConfig?.linked_email ?? '').trim().toLowerCase();
261
+ if (boundEmail && currentEmail && boundEmail !== currentEmail) {
262
+ graphConnected = false;
263
+ hasUsableExistingConfig = false;
264
+ console.log(chalk.yellow(` Existing product binding is for ${existingConfig?.linked_email}, not ${email}.`));
265
+ console.log(chalk.dim(' Will not use this graph for the current account.\n'));
266
+ }
267
+ else {
268
+ const { products, requestOk } = await fetchProducts(idToken, { staging: options.staging });
269
+ if (requestOk) {
270
+ const canAccessBoundProduct = products.some((p) => p.id === existingConfig?.product_id);
271
+ if (!canAccessBoundProduct) {
272
+ graphConnected = false;
273
+ hasUsableExistingConfig = false;
274
+ console.log(chalk.yellow(` Existing product graph is not accessible from ${email}.`));
275
+ console.log(chalk.dim(' Re-linking is required for this account.\n'));
276
+ }
277
+ }
278
+ }
279
+ }
280
+ if (tier === 'premium' && idToken && !hasUsableExistingConfig) {
281
+ const productSpinner = ora('Fetching your product graphs...').start();
282
+ const { products, requestOk } = await fetchProducts(idToken, { staging: options.staging });
283
+ productSpinner.stop();
284
+ if (requestOk && products.length > 0) {
285
+ console.log(chalk.bold(' Connect to a product graph\n'));
286
+ products.forEach((p, i) => {
287
+ const date = p.createdAt ? chalk.dim(` (${new Date(p.createdAt).toLocaleDateString()})`) : '';
288
+ console.log(` ${chalk.cyan(`${i + 1}.`)} ${chalk.white(p.name)}${date}`);
289
+ if (p.brief)
290
+ console.log(` ${chalk.dim(p.brief)}`);
291
+ });
292
+ console.log(` ${chalk.dim(`${products.length + 1}.`)} ${chalk.dim('Skip — I\'ll connect later')}`);
293
+ console.log();
294
+ const answer = await prompt(chalk.cyan(' Select a product (number): '));
295
+ const choice = parseInt(answer, 10);
296
+ if (choice >= 1 && choice <= products.length) {
297
+ const selected = products[choice - 1];
298
+ mkdirSync(join(projectRoot, '.cutline'), { recursive: true });
299
+ writeFileSync(configPath, JSON.stringify({
300
+ product_id: selected.id,
301
+ product_name: selected.name,
302
+ linked_email: email ?? null,
303
+ }, null, 2) + '\n');
304
+ console.log(chalk.green(`\n ✓ Connected to "${selected.name}"`));
305
+ console.log(chalk.dim(` ${configPath}\n`));
306
+ graphConnected = true;
307
+ }
308
+ else {
309
+ console.log(chalk.dim('\n Skipped. Run `cutline-mcp setup` again to connect later.\n'));
310
+ }
311
+ }
312
+ else if (requestOk) {
313
+ console.log(chalk.dim(' No completed product graphs found.'));
314
+ console.log(chalk.dim(' Ask your AI agent to "Run a deep dive on my product idea" first,'));
315
+ console.log(chalk.dim(' then re-run'), chalk.cyan('cutline-mcp setup'), chalk.dim('to connect it.'));
316
+ console.log();
317
+ }
318
+ else {
319
+ console.log(chalk.yellow(' Could not verify product graphs (network/auth issue).'));
320
+ console.log(chalk.dim(' Re-run'), chalk.cyan('cutline-mcp setup'), chalk.dim('after auth/network is healthy.\n'));
321
+ }
322
+ }
323
+ else if (hasUsableExistingConfig) {
324
+ try {
325
+ const existing = existingConfig ?? JSON.parse(readFileSync(configPath, 'utf-8'));
326
+ console.log(chalk.green(` ✓ Connected to product graph:`), chalk.white(existing.product_name || existing.product_id));
327
+ console.log();
328
+ }
329
+ catch { /* ignore parse errors */ }
330
+ }
331
+ const hiddenAuditDimensions = await resolveHiddenAuditDimensions(options);
332
+ writeAuditVisibilityConfig(hiddenAuditDimensions);
333
+ if (hiddenAuditDimensions.length > 0) {
334
+ console.log(chalk.dim(` Hidden audit dimensions: ${hiddenAuditDimensions.join(', ')}\n`));
335
+ }
336
+ else {
337
+ console.log(chalk.dim(' Hidden audit dimensions: none\n'));
338
+ }
339
+ // ── 3. Write MCP server config to IDEs ───────────────────────────────────
340
+ const runtime = resolveServeRuntime();
341
+ const serverConfig = buildServerConfig(runtime);
342
+ const home = homedir();
343
+ const ideConfigs = [
344
+ { name: 'Cursor', path: join(home, '.cursor', 'mcp.json') },
345
+ { name: 'Claude Code', path: join(home, '.claude.json') },
346
+ ];
347
+ let wroteAny = false;
348
+ for (const ide of ideConfigs) {
349
+ try {
350
+ mergeIdeConfig(ide.path, serverConfig);
351
+ console.log(chalk.green(` ✓ ${ide.name}`), chalk.dim(ide.path));
352
+ wroteAny = true;
353
+ }
354
+ catch (err) {
355
+ console.log(chalk.red(` ✗ ${ide.name}`), chalk.dim(err.message));
356
+ }
357
+ }
358
+ if (wroteAny) {
359
+ console.log(chalk.dim('\n MCP server entries merged into IDE config (existing servers preserved).\n'));
360
+ if (runtime.source === 'binary') {
361
+ console.log(chalk.dim(` Using local MCP binary: ${runtime.command}\n`));
362
+ }
363
+ else {
364
+ console.log(chalk.dim(' Local `cutline-mcp` binary not found — using npx fallback.\n'));
365
+ }
366
+ }
367
+ else {
368
+ console.log(chalk.yellow('\n No IDE config files found. Printing config for manual setup:\n'));
369
+ console.log(chalk.green(JSON.stringify({ mcpServers: serverConfig }, null, 2)));
370
+ console.log();
371
+ }
372
+ // ── 4. Generate IDE rules ────────────────────────────────────────────────
373
+ console.log(chalk.bold(' Generating IDE rules...\n'));
374
+ await initCommand({ projectRoot: options.projectRoot, staging: options.staging });
375
+ // ── 5. Claude Code one-liners ────────────────────────────────────────────
376
+ console.log(chalk.bold(' Claude Code one-liner alternative:\n'));
377
+ console.log(chalk.dim(' If you prefer `claude mcp add` instead of ~/.claude.json:\n'));
378
+ const coreServers = ['constraints', 'premortem', 'tools', 'exploration'];
379
+ for (const name of coreServers) {
380
+ const invocation = [runtime.command, ...runtime.argsPrefix, 'serve', name].join(' ');
381
+ console.log(chalk.cyan(` claude mcp add cutline-${name} -- ${invocation}`));
382
+ }
383
+ console.log();
384
+ // ── 6. What you can do ───────────────────────────────────────────────────
385
+ console.log(chalk.bold(' Start a new terminal or restart your MCP servers, then ask your AI agent:\n'));
386
+ if (tier === 'premium') {
387
+ if (!graphConnected) {
388
+ console.log(` ${chalk.cyan('→')} ${chalk.white('cutline-mcp setup')} ${chalk.dim('(re-run to connect a product graph)')}`);
389
+ console.log(` ${chalk.dim('Link a pre-mortem to unlock constraint-aware code guidance')}`);
390
+ }
391
+ const items = [
392
+ { cmd: 'use cutline', desc: 'Magic phrase (also works with "use cutline to...", "using cutline...", "with cutline...") — Cutline infers intent and routes to the right flow' },
393
+ { cmd: 'Run a deep dive on my product idea', desc: 'Pre-mortem analysis — risks, assumptions, experiments' },
394
+ { cmd: 'Plan this feature with constraints from my product', desc: 'RGR plan — constraint-aware implementation roadmap' },
395
+ { cmd: 'Run a security vibe check on this codebase', desc: 'Free security vibe check (`code_audit`) — security, reliability, and scalability (generic, not product-linked)' },
396
+ { cmd: 'Run an engineering vibe check for my product', desc: 'Premium deep vibe check (`engineering_audit`) — product-linked analysis + RGR remediation plan' },
397
+ { cmd: 'Check constraints for src/api/upload.ts', desc: 'Get NFR boundaries for a specific file' },
398
+ { cmd: 'Generate .cutline.md for my product', desc: 'Write the constraint routing engine' },
399
+ { cmd: 'What does my persona think about X?', desc: 'AI persona feedback on features' },
400
+ ...(!graphConnected ? [{ cmd: 'Connect my Cutline product graph', desc: 'Link a completed pre-mortem for constraint-aware code guidance' }] : []),
401
+ ];
402
+ for (const item of items) {
403
+ console.log(` ${chalk.cyan('→')} ${chalk.white(`"${item.cmd}"`)}`);
404
+ console.log(` ${chalk.dim(item.desc)}`);
405
+ }
406
+ }
407
+ else {
408
+ const items = [
409
+ { cmd: 'use cutline', desc: 'Magic phrase (also works with "use cutline to...", "using cutline...", "with cutline...") — Cutline routes to the highest-value free flow for your intent' },
410
+ { cmd: 'Run a security vibe check on this codebase', desc: 'Free security vibe check (`code_audit`) — security, reliability, and scalability scan (3/month free)' },
411
+ { cmd: 'Explore a product idea', desc: 'Free 6-act discovery flow to identify pain points and opportunities' },
412
+ { cmd: 'Continue my exploration session', desc: 'Resume and refine an existing free exploration conversation' },
413
+ ];
414
+ for (const item of items) {
415
+ console.log(` ${chalk.cyan('→')} ${chalk.white(`"${item.cmd}"`)}`);
416
+ console.log(` ${chalk.dim(item.desc)}`);
417
+ }
418
+ console.log();
419
+ console.log(chalk.dim(' Want product-linked constraints, full code audit + RGR plans, and pre-mortem deep dives?'));
420
+ console.log(chalk.dim(' →'), chalk.cyan('cutline-mcp upgrade'), chalk.dim('or https://thecutline.ai/upgrade'));
421
+ }
422
+ console.log();
423
+ console.log(chalk.dim(` cutline-mcp v${version} · docs: https://thecutline.ai/docs/setup`));
424
+ console.log();
425
+ }
@@ -0,0 +1,3 @@
1
+ export declare function statusCommand(options: {
2
+ staging?: boolean;
3
+ }): Promise<void>;
@@ -0,0 +1,127 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import { getRefreshToken } from '../auth/keychain.js';
4
+ import { fetchFirebaseApiKey } from '../utils/config.js';
5
+ async function getSubscriptionStatus(idToken, isStaging) {
6
+ try {
7
+ const baseUrl = isStaging
8
+ ? 'https://us-central1-cutline-staging.cloudfunctions.net'
9
+ : 'https://us-central1-cutline-prod.cloudfunctions.net';
10
+ const response = await fetch(`${baseUrl}/mcpSubscriptionStatus`, {
11
+ method: 'GET',
12
+ headers: {
13
+ 'Authorization': `Bearer ${idToken}`,
14
+ },
15
+ });
16
+ if (!response.ok) {
17
+ return { status: 'unknown' };
18
+ }
19
+ return await response.json();
20
+ }
21
+ catch (error) {
22
+ console.error('Error fetching subscription:', error);
23
+ return { status: 'unknown' };
24
+ }
25
+ }
26
+ async function exchangeRefreshToken(refreshToken, apiKey) {
27
+ const response = await fetch(`https://securetoken.googleapis.com/v1/token?key=${apiKey}`, {
28
+ method: 'POST',
29
+ headers: { 'Content-Type': 'application/json' },
30
+ body: JSON.stringify({
31
+ grant_type: 'refresh_token',
32
+ refresh_token: refreshToken,
33
+ }),
34
+ });
35
+ if (!response.ok) {
36
+ const errorText = await response.text();
37
+ let errorMessage = 'Failed to refresh token';
38
+ try {
39
+ const errorData = JSON.parse(errorText);
40
+ errorMessage = errorData.error?.message || errorText;
41
+ }
42
+ catch {
43
+ errorMessage = errorText;
44
+ }
45
+ throw new Error(errorMessage);
46
+ }
47
+ const data = await response.json();
48
+ return data.id_token;
49
+ }
50
+ export async function statusCommand(options) {
51
+ console.log(chalk.bold('\n📊 Cutline MCP Status\n'));
52
+ const spinner = ora('Checking authentication...').start();
53
+ try {
54
+ // Check for stored refresh token
55
+ const refreshToken = await getRefreshToken();
56
+ if (!refreshToken) {
57
+ spinner.info(chalk.yellow('Not authenticated'));
58
+ console.log(chalk.gray(' Run'), chalk.cyan('cutline-mcp login'), chalk.gray('to authenticate\n'));
59
+ return;
60
+ }
61
+ // Get Firebase API key
62
+ spinner.text = 'Fetching configuration...';
63
+ let firebaseApiKey;
64
+ try {
65
+ firebaseApiKey = await fetchFirebaseApiKey(options);
66
+ }
67
+ catch (error) {
68
+ spinner.fail(chalk.red('Configuration error'));
69
+ console.error(chalk.red(` ${error instanceof Error ? error.message : 'Failed to get Firebase API key'}`));
70
+ process.exit(1);
71
+ }
72
+ // Exchange refresh token for ID token
73
+ spinner.text = 'Verifying credentials...';
74
+ const idToken = await exchangeRefreshToken(refreshToken, firebaseApiKey);
75
+ // Decode JWT payload (base64) to get user info - no verification needed, just display
76
+ const payloadBase64 = idToken.split('.')[1];
77
+ const decoded = JSON.parse(Buffer.from(payloadBase64, 'base64').toString());
78
+ spinner.succeed(chalk.green('Authenticated'));
79
+ console.log(chalk.gray(' User:'), chalk.white(decoded.email || decoded.user_id || decoded.sub));
80
+ console.log(chalk.gray(' UID:'), chalk.dim(decoded.user_id || decoded.sub));
81
+ // Calculate token expiry
82
+ const expiresIn = Math.floor((decoded.exp * 1000 - Date.now()) / 1000 / 60);
83
+ console.log(chalk.gray(' Token expires in:'), chalk.white(`${expiresIn} minutes`));
84
+ // Show custom claims if present
85
+ if (decoded.mcp) {
86
+ console.log(chalk.gray(' MCP enabled:'), chalk.green('✓'));
87
+ }
88
+ if (decoded.deviceId) {
89
+ console.log(chalk.gray(' Device ID:'), chalk.dim(decoded.deviceId));
90
+ }
91
+ // Check subscription status via Cloud Function
92
+ spinner.start('Checking subscription...');
93
+ const subscription = await getSubscriptionStatus(idToken, !!options.staging);
94
+ spinner.stop();
95
+ if (subscription.status === 'active' || subscription.status === 'trialing') {
96
+ const statusLabel = subscription.status === 'trialing' ? ' (trial)' : '';
97
+ console.log(chalk.gray(' Plan:'), chalk.green(`✓ ${subscription.planName || 'Premium'}${statusLabel}`));
98
+ if (subscription.periodEnd) {
99
+ const periodEndDate = new Date(subscription.periodEnd);
100
+ const daysLeft = Math.ceil((periodEndDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
101
+ console.log(chalk.gray(' Renews:'), chalk.white(`${periodEndDate.toLocaleDateString()} (${daysLeft} days)`));
102
+ }
103
+ }
104
+ else if (subscription.status === 'past_due') {
105
+ console.log(chalk.gray(' Plan:'), chalk.yellow('⚠ Premium (payment past due)'));
106
+ }
107
+ else if (subscription.status === 'canceled') {
108
+ console.log(chalk.gray(' Plan:'), chalk.yellow('Premium (canceled)'));
109
+ if (subscription.periodEnd) {
110
+ console.log(chalk.gray(' Access until:'), chalk.white(new Date(subscription.periodEnd).toLocaleDateString()));
111
+ }
112
+ }
113
+ else {
114
+ console.log(chalk.gray(' Plan:'), chalk.white('Free'));
115
+ console.log(chalk.dim(' Upgrade at'), chalk.cyan('https://thecutline.ai/pricing'));
116
+ }
117
+ console.log();
118
+ }
119
+ catch (error) {
120
+ spinner.fail(chalk.red('Status check failed'));
121
+ if (error instanceof Error) {
122
+ console.error(chalk.red(` ${error.message}`));
123
+ console.log(chalk.gray(' Try running'), chalk.cyan('cutline-mcp login'), chalk.gray('again\n'));
124
+ }
125
+ process.exit(1);
126
+ }
127
+ }
@@ -0,0 +1,3 @@
1
+ export declare function upgradeCommand(options: {
2
+ staging?: boolean;
3
+ }): Promise<void>;
@@ -0,0 +1,112 @@
1
+ import open from 'open';
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ import { startCallbackServer } from '../auth/callback.js';
5
+ import { storeRefreshToken } from '../auth/keychain.js';
6
+ import { saveConfig } from '../utils/config-store.js';
7
+ import { getConfig, fetchFirebaseApiKey } from '../utils/config.js';
8
+ async function exchangeCustomToken(customToken, apiKey) {
9
+ const response = await fetch(`https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${apiKey}`, {
10
+ method: 'POST',
11
+ headers: { 'Content-Type': 'application/json' },
12
+ body: JSON.stringify({
13
+ token: customToken,
14
+ returnSecureToken: true,
15
+ }),
16
+ });
17
+ if (!response.ok) {
18
+ const error = await response.text();
19
+ throw new Error(`Failed to exchange custom token: ${error}`);
20
+ }
21
+ const data = await response.json();
22
+ return {
23
+ refreshToken: data.refreshToken,
24
+ email: data.email,
25
+ };
26
+ }
27
+ export async function upgradeCommand(options) {
28
+ const config = getConfig(options);
29
+ console.log(chalk.bold('\n⬆️ Cutline MCP - Upgrade to Premium\n'));
30
+ if (options.staging) {
31
+ console.log(chalk.yellow(' ⚠️ Using STAGING environment\n'));
32
+ }
33
+ // Fetch Firebase API key
34
+ let firebaseApiKey;
35
+ try {
36
+ firebaseApiKey = await fetchFirebaseApiKey(options);
37
+ }
38
+ catch (error) {
39
+ console.error(chalk.red(`Error: ${error instanceof Error ? error.message : 'Failed to get Firebase API key'}`));
40
+ process.exit(1);
41
+ }
42
+ // Determine upgrade URL based on environment
43
+ const baseUrl = options.staging
44
+ ? 'https://cutline-staging.web.app'
45
+ : 'https://thecutline.ai';
46
+ console.log(chalk.gray(' Opening upgrade page in your browser...\n'));
47
+ console.log(chalk.dim(' After upgrading, your MCP session will be refreshed automatically.\n'));
48
+ const spinner = ora('Waiting for upgrade and re-authentication...').start();
49
+ try {
50
+ // Start callback server for re-auth after upgrade
51
+ const serverPromise = startCallbackServer('upgrade');
52
+ // Open upgrade page with callback for re-auth
53
+ // The upgrade page will redirect to mcp-auth after successful upgrade
54
+ const upgradeUrl = `${baseUrl}/upgrade?mcp_callback=${encodeURIComponent(config.CALLBACK_URL)}`;
55
+ await open(upgradeUrl);
56
+ spinner.text = 'Browser opened - complete your upgrade, then re-authenticate';
57
+ // Wait for callback with new token (after upgrade + re-auth)
58
+ const result = await serverPromise;
59
+ // Exchange custom token for refresh token
60
+ spinner.text = 'Refreshing your session...';
61
+ const { refreshToken, email } = await exchangeCustomToken(result.token, firebaseApiKey);
62
+ // Store refresh token
63
+ try {
64
+ await storeRefreshToken(refreshToken);
65
+ }
66
+ catch (error) {
67
+ console.warn(chalk.yellow(' ⚠️ Could not save to Keychain (skipping)'));
68
+ }
69
+ // Save to file config (API key is fetched at runtime, not stored)
70
+ try {
71
+ saveConfig({
72
+ refreshToken,
73
+ environment: options.staging ? 'staging' : 'production',
74
+ });
75
+ }
76
+ catch (error) {
77
+ console.error(chalk.red(' ✗ Failed to save config file:'), error);
78
+ }
79
+ spinner.succeed(chalk.green('Upgrade complete! Session refreshed.'));
80
+ const envLabel = options.staging ? chalk.yellow('STAGING') : chalk.green('PRODUCTION');
81
+ console.log(chalk.gray(` Environment: ${envLabel}`));
82
+ if (email || result.email) {
83
+ console.log(chalk.gray(` Account: ${email || result.email}`));
84
+ }
85
+ console.log(chalk.green('\n Premium features are now available!\n'));
86
+ console.log(chalk.bold(' Re-run init to update your IDE rules:'));
87
+ console.log(chalk.cyan(' cutline-mcp init\n'));
88
+ console.log(chalk.bold(' Then ask your AI agent:\n'));
89
+ const items = [
90
+ { cmd: 'use cutline', desc: 'Magic phrase (also works with "use cutline to...", "using cutline...", "with cutline...") — Cutline infers intent and routes automatically' },
91
+ { cmd: 'Run a deep dive on my product idea', desc: 'Pre-mortem analysis — risks, assumptions, experiments' },
92
+ { cmd: 'Plan this feature with constraints from my product', desc: 'RGR plan — constraint-aware implementation roadmap' },
93
+ { cmd: 'Run a code audit on this codebase', desc: 'Free code audit — security, reliability, and scalability (generic, not product-linked)' },
94
+ { cmd: 'Run an engineering audit for my product', desc: 'Premium deep audit — product-linked analysis + RGR remediation plan' },
95
+ { cmd: 'Generate .cutline.md for my product', desc: 'Write the constraint routing engine' },
96
+ ];
97
+ for (const item of items) {
98
+ console.log(` ${chalk.cyan('→')} ${chalk.white(`"${item.cmd}"`)}`);
99
+ console.log(` ${chalk.dim(item.desc)}`);
100
+ }
101
+ console.log();
102
+ }
103
+ catch (error) {
104
+ spinner.fail(chalk.red('Upgrade flow failed'));
105
+ if (error instanceof Error) {
106
+ console.error(chalk.red(` ${error.message}`));
107
+ }
108
+ console.log(chalk.gray('\n You can also upgrade at:'), chalk.cyan(`${baseUrl}/upgrade`));
109
+ console.log(chalk.gray(' Then run:'), chalk.cyan('cutline-mcp login'), chalk.gray('to refresh your session\n'));
110
+ process.exit(1);
111
+ }
112
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};