@splicr/mcp-server 0.13.0 → 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.
package/dist/cli.js CHANGED
@@ -10,7 +10,7 @@
10
10
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
11
11
  import { execSync } from 'child_process';
12
12
  import { homedir } from 'os';
13
- import { join } from 'path';
13
+ import { join, basename } from 'path';
14
14
  const command = process.argv[2];
15
15
  const subCommand = process.argv[3];
16
16
  // Parse --join flag from any position
@@ -195,6 +195,9 @@ async function main() {
195
195
  case 'stop':
196
196
  await runStop();
197
197
  break;
198
+ case 'index':
199
+ await runIndex();
200
+ break;
198
201
  case 'dashboard':
199
202
  case 'open': {
200
203
  const url = 'https://splicr.dev/dashboard';
@@ -1142,6 +1145,190 @@ function printManualConfig() {
1142
1145
  console.error(' }');
1143
1146
  console.error(' }\n');
1144
1147
  }
1148
+ // ─── splicr index ───
1149
+ async function runIndex() {
1150
+ const target = process.argv[3];
1151
+ if (!target) {
1152
+ console.error('Usage: splicr index <path-or-url>');
1153
+ console.error(' splicr index ./docs/ Index all supported files in directory');
1154
+ console.error(' splicr index ./api.yaml Index a single file');
1155
+ console.error(' splicr index https://example.com Index a URL');
1156
+ process.exit(1);
1157
+ }
1158
+ const { hasAuth } = await import('./auth.js');
1159
+ if (!hasAuth()) {
1160
+ console.error('Not authenticated. Run: splicr setup');
1161
+ process.exit(1);
1162
+ }
1163
+ // Detect project for routing
1164
+ const { detectProject } = await import('./lib/project-detector.js');
1165
+ const project = await detectProject(process.cwd()).catch(() => null);
1166
+ const projectName = project?.name;
1167
+ if (projectName) {
1168
+ console.error(`Project: ${projectName}`);
1169
+ }
1170
+ // URL?
1171
+ if (target.startsWith('http://') || target.startsWith('https://')) {
1172
+ await indexUrl(target, projectName);
1173
+ return;
1174
+ }
1175
+ // File or directory
1176
+ const { resolve } = await import('path');
1177
+ const { statSync } = await import('fs');
1178
+ const resolved = resolve(process.cwd(), target);
1179
+ let stat;
1180
+ try {
1181
+ stat = statSync(resolved);
1182
+ }
1183
+ catch {
1184
+ console.error(`Not found: ${resolved}`);
1185
+ process.exit(1);
1186
+ }
1187
+ if (stat.isDirectory()) {
1188
+ await indexDirectory(resolved, projectName);
1189
+ }
1190
+ else {
1191
+ const result = await indexSingleFile(resolved, projectName);
1192
+ if (result) {
1193
+ console.error(`\nDone: 1 ${result}`);
1194
+ }
1195
+ }
1196
+ }
1197
+ function findGitRoot(startPath) {
1198
+ try {
1199
+ return execSync('git rev-parse --show-toplevel', {
1200
+ cwd: startPath,
1201
+ encoding: 'utf-8',
1202
+ timeout: 2000,
1203
+ stdio: ['pipe', 'pipe', 'pipe'],
1204
+ }).trim();
1205
+ }
1206
+ catch {
1207
+ return null;
1208
+ }
1209
+ }
1210
+ async function indexSingleFile(filePath, projectName) {
1211
+ const { parseFile } = await import('./lib/file-parser.js');
1212
+ const { indexFile } = await import('./lib/api-client.js');
1213
+ const { normalizePath } = await import('./lib/project-detector.js');
1214
+ const { relative, dirname } = await import('path');
1215
+ let parsed;
1216
+ try {
1217
+ parsed = parseFile(filePath);
1218
+ }
1219
+ catch (err) {
1220
+ const short = basename(filePath);
1221
+ console.error(` x ${short} - ${err.message}`);
1222
+ return null;
1223
+ }
1224
+ // Build source_url as file:// relative to git root
1225
+ const gitRoot = findGitRoot(dirname(filePath)) || process.cwd();
1226
+ const relPath = normalizePath(relative(gitRoot, filePath));
1227
+ const sourceUrl = `file://${relPath}`;
1228
+ const shortPath = relPath.length > 60 ? '...' + relPath.slice(-57) : relPath;
1229
+ try {
1230
+ const result = await indexFile({
1231
+ content: parsed.content,
1232
+ title: parsed.title,
1233
+ source_url: sourceUrl,
1234
+ file_type: parsed.file_type,
1235
+ project_name: projectName,
1236
+ tags: [...parsed.tags, 'indexed'],
1237
+ content_category: parsed.content_category,
1238
+ metadata: { word_count: parsed.word_count, file_type: parsed.file_type },
1239
+ });
1240
+ const status = result.status === 'updated' ? 'updated' : 'created';
1241
+ console.error(` ${status === 'updated' ? '~' : '+'} ${shortPath}`);
1242
+ return status;
1243
+ }
1244
+ catch (err) {
1245
+ console.error(` x ${shortPath} - ${err.message}`);
1246
+ return null;
1247
+ }
1248
+ }
1249
+ async function indexDirectory(dirPath, projectName) {
1250
+ const { readdirSync, statSync: fsStat } = await import('fs');
1251
+ const { join: pathJoin } = await import('path');
1252
+ const { isSupported, getExtension } = await import('./lib/file-parser.js');
1253
+ const SKIP_DIRS = new Set([
1254
+ 'node_modules', '.git', 'dist', 'build', 'coverage',
1255
+ '.next', '__pycache__', '.turbo', '.cache', '.expo',
1256
+ 'vendor', '.venv', 'venv', 'target', '.output',
1257
+ ]);
1258
+ const MAX_FILES = 50;
1259
+ const files = [];
1260
+ function walk(dir) {
1261
+ if (files.length >= MAX_FILES)
1262
+ return;
1263
+ try {
1264
+ const entries = readdirSync(dir, { withFileTypes: true });
1265
+ for (const entry of entries) {
1266
+ if (files.length >= MAX_FILES)
1267
+ return;
1268
+ if (entry.name.startsWith('.'))
1269
+ continue;
1270
+ const full = pathJoin(dir, entry.name);
1271
+ if (entry.isDirectory()) {
1272
+ if (!SKIP_DIRS.has(entry.name))
1273
+ walk(full);
1274
+ }
1275
+ else if (isSupported(getExtension(entry.name))) {
1276
+ files.push(full);
1277
+ }
1278
+ }
1279
+ }
1280
+ catch { /* skip unreadable dirs */ }
1281
+ }
1282
+ walk(dirPath);
1283
+ if (files.length === 0) {
1284
+ console.error('No supported files found.');
1285
+ return;
1286
+ }
1287
+ console.error(`Indexing ${files.length} file(s)${files.length >= MAX_FILES ? ` (capped at ${MAX_FILES})` : ''}...\n`);
1288
+ let created = 0;
1289
+ let updated = 0;
1290
+ let failed = 0;
1291
+ // Process with concurrency limit of 5
1292
+ const CONCURRENCY = 5;
1293
+ for (let i = 0; i < files.length; i += CONCURRENCY) {
1294
+ const batch = files.slice(i, i + CONCURRENCY);
1295
+ const results = await Promise.allSettled(batch.map(f => indexSingleFile(f, projectName)));
1296
+ for (const r of results) {
1297
+ if (r.status === 'fulfilled' && r.value) {
1298
+ if (r.value === 'updated')
1299
+ updated++;
1300
+ else
1301
+ created++;
1302
+ }
1303
+ else {
1304
+ failed++;
1305
+ }
1306
+ }
1307
+ }
1308
+ console.error(`\nDone: ${created} indexed, ${updated} updated, ${failed} failed`);
1309
+ }
1310
+ async function indexUrl(url, projectName) {
1311
+ const { saveFromAgent } = await import('./lib/api-client.js');
1312
+ console.error(`Indexing URL: ${url}`);
1313
+ try {
1314
+ const result = await saveFromAgent({
1315
+ content: url,
1316
+ title: url,
1317
+ project_name: projectName,
1318
+ tags: ['indexed', 'url'],
1319
+ });
1320
+ if (result.duplicate) {
1321
+ console.error(' ~ Already indexed');
1322
+ }
1323
+ else {
1324
+ console.error(` + Saved`);
1325
+ }
1326
+ }
1327
+ catch (err) {
1328
+ console.error(` x Failed: ${err.message}`);
1329
+ process.exit(1);
1330
+ }
1331
+ }
1145
1332
  function printHelp() {
1146
1333
  console.error(`
1147
1334
  Splicr — route what you read to what you're building
@@ -1157,6 +1344,8 @@ function printHelp() {
1157
1344
  team list List your teams
1158
1345
  team invite Show invite link for your team
1159
1346
  team join <code> Join a team by invite code
1347
+ index <path> Index local files into your knowledge base
1348
+ index <dir> Index all supported files in a directory
1160
1349
  dashboard Open knowledge dashboard in browser
1161
1350
  uninstall Remove Splicr from all coding agents
1162
1351
 
@@ -36,6 +36,20 @@ export declare function saveFromAgent(params: {
36
36
  project_name?: string;
37
37
  duplicate: boolean;
38
38
  }>;
39
+ export declare function indexFile(params: {
40
+ content: string;
41
+ title: string;
42
+ source_url: string;
43
+ file_type?: string;
44
+ project_name?: string;
45
+ tags?: string[];
46
+ content_category?: string;
47
+ metadata?: Record<string, unknown>;
48
+ }): Promise<{
49
+ id: string;
50
+ title: string;
51
+ status: 'created' | 'updated';
52
+ }>;
39
53
  export declare function resolveProject(params: {
40
54
  local_path?: string;
41
55
  git_remote_url?: string;
@@ -58,6 +58,9 @@ export async function getRecentInsights(params) {
58
58
  export async function saveFromAgent(params) {
59
59
  return await apiRequest('POST', '/mcp/save', params);
60
60
  }
61
+ export async function indexFile(params) {
62
+ return await apiRequest('POST', '/mcp/index', params);
63
+ }
61
64
  export async function resolveProject(params) {
62
65
  return await apiRequest('POST', '/mcp/resolve-project', params);
63
66
  }
@@ -0,0 +1,11 @@
1
+ export interface ParsedFile {
2
+ title: string;
3
+ content: string;
4
+ file_type: string;
5
+ content_category: string;
6
+ tags: string[];
7
+ word_count: number;
8
+ }
9
+ export declare function getExtension(filename: string): string;
10
+ export declare function isSupported(ext: string): boolean;
11
+ export declare function parseFile(filePath: string): ParsedFile;
@@ -0,0 +1,329 @@
1
+ import { readFileSync, statSync } from 'fs';
2
+ import { basename, extname } from 'path';
3
+ import yaml from 'js-yaml';
4
+ const MAX_RAW_SIZE = 1024 * 1024; // 1MB
5
+ const MAX_CONTENT_CHARS = 50_000;
6
+ const MAX_OPENAPI_CHARS = 20_000;
7
+ const MAX_CODE_CHARS = 10_000;
8
+ const SUPPORTED_EXTENSIONS = new Set([
9
+ '.md', '.txt', '.rst', '.adoc',
10
+ '.yaml', '.yml', '.json',
11
+ '.ts', '.tsx', '.js', '.jsx',
12
+ '.py', '.go', '.rs', '.java',
13
+ '.css', '.scss',
14
+ ]);
15
+ export function getExtension(filename) {
16
+ return extname(filename).toLowerCase();
17
+ }
18
+ export function isSupported(ext) {
19
+ return SUPPORTED_EXTENSIONS.has(ext);
20
+ }
21
+ export function parseFile(filePath) {
22
+ const stat = statSync(filePath);
23
+ if (stat.size > MAX_RAW_SIZE) {
24
+ throw new Error(`File too large (${(stat.size / 1024).toFixed(0)}KB > 1MB limit)`);
25
+ }
26
+ const raw = readFileSync(filePath, 'utf-8');
27
+ const filename = basename(filePath);
28
+ const ext = getExtension(filename);
29
+ switch (ext) {
30
+ case '.md':
31
+ case '.rst':
32
+ case '.adoc':
33
+ return parseMarkdown(raw, filename);
34
+ case '.txt':
35
+ return parseText(raw, filename);
36
+ case '.yaml':
37
+ case '.yml':
38
+ return parseYamlOrOpenApi(raw, filename);
39
+ case '.json':
40
+ return parseJson(raw, filename);
41
+ case '.ts':
42
+ case '.tsx':
43
+ case '.js':
44
+ case '.jsx':
45
+ return parseCodeSurface(raw, filename, 'typescript');
46
+ case '.py':
47
+ return parseCodeSurface(raw, filename, 'python');
48
+ case '.go':
49
+ return parseCodeSurface(raw, filename, 'go');
50
+ case '.rs':
51
+ return parseCodeSurface(raw, filename, 'rust');
52
+ case '.java':
53
+ return parseCodeSurface(raw, filename, 'java');
54
+ case '.css':
55
+ case '.scss':
56
+ return parseText(raw, filename);
57
+ default:
58
+ return parseText(raw, filename);
59
+ }
60
+ }
61
+ // --- Parsers ---
62
+ function parseMarkdown(raw, filename) {
63
+ const tags = ['markdown'];
64
+ // Extract frontmatter tags if present
65
+ const fmMatch = raw.match(/^---\n([\s\S]*?)\n---/);
66
+ if (fmMatch) {
67
+ const fmContent = fmMatch[1];
68
+ const tagMatch = fmContent.match(/tags:\s*\[([^\]]+)\]/);
69
+ if (tagMatch) {
70
+ tags.push(...tagMatch[1].split(',').map(t => t.trim().replace(/['"]/g, '')));
71
+ }
72
+ }
73
+ // Extract title from first heading
74
+ const titleMatch = raw.match(/^#\s+(.+)$/m);
75
+ const title = titleMatch ? titleMatch[1].trim() : filename;
76
+ const content = raw.substring(0, MAX_CONTENT_CHARS);
77
+ return {
78
+ title,
79
+ content,
80
+ file_type: 'markdown',
81
+ content_category: 'other',
82
+ tags,
83
+ word_count: content.split(/\s+/).length,
84
+ };
85
+ }
86
+ function parseText(raw, filename) {
87
+ const content = raw.substring(0, MAX_CONTENT_CHARS);
88
+ return {
89
+ title: filename,
90
+ content,
91
+ file_type: 'text',
92
+ content_category: 'other',
93
+ tags: ['text'],
94
+ word_count: content.split(/\s+/).length,
95
+ };
96
+ }
97
+ function parseJson(raw, filename) {
98
+ // For package.json, extract useful summary
99
+ if (filename === 'package.json') {
100
+ try {
101
+ const pkg = JSON.parse(raw);
102
+ const lines = [];
103
+ if (pkg.name)
104
+ lines.push(`Package: ${pkg.name}`);
105
+ if (pkg.description)
106
+ lines.push(`Description: ${pkg.description}`);
107
+ if (pkg.scripts)
108
+ lines.push(`\nScripts:\n${Object.entries(pkg.scripts).map(([k, v]) => ` ${k}: ${v}`).join('\n')}`);
109
+ if (pkg.dependencies)
110
+ lines.push(`\nDependencies:\n${Object.keys(pkg.dependencies).map(d => ` - ${d}`).join('\n')}`);
111
+ if (pkg.devDependencies)
112
+ lines.push(`\nDev Dependencies:\n${Object.keys(pkg.devDependencies).map(d => ` - ${d}`).join('\n')}`);
113
+ return {
114
+ title: pkg.name || filename,
115
+ content: lines.join('\n'),
116
+ file_type: 'json',
117
+ content_category: 'tool',
118
+ tags: ['json', 'package'],
119
+ word_count: lines.join('\n').split(/\s+/).length,
120
+ };
121
+ }
122
+ catch { /* fall through to raw */ }
123
+ }
124
+ const content = raw.substring(0, MAX_CONTENT_CHARS);
125
+ return {
126
+ title: filename,
127
+ content,
128
+ file_type: 'json',
129
+ content_category: 'other',
130
+ tags: ['json'],
131
+ word_count: content.split(/\s+/).length,
132
+ };
133
+ }
134
+ function parseYamlOrOpenApi(raw, filename) {
135
+ let parsed;
136
+ try {
137
+ parsed = yaml.load(raw);
138
+ }
139
+ catch {
140
+ // Invalid YAML - treat as text
141
+ return parseText(raw, filename);
142
+ }
143
+ if (!parsed || typeof parsed !== 'object') {
144
+ return parseText(raw, filename);
145
+ }
146
+ // Detect OpenAPI
147
+ if (parsed.openapi || parsed.swagger) {
148
+ return parseOpenApi(parsed, filename);
149
+ }
150
+ // Regular YAML - return as-is
151
+ const content = raw.substring(0, MAX_CONTENT_CHARS);
152
+ return {
153
+ title: filename,
154
+ content,
155
+ file_type: 'yaml',
156
+ content_category: 'other',
157
+ tags: ['yaml'],
158
+ word_count: content.split(/\s+/).length,
159
+ };
160
+ }
161
+ function parseOpenApi(spec, filename) {
162
+ const lines = [];
163
+ // Header
164
+ const title = spec.info?.title || filename;
165
+ const version = spec.info?.version || '';
166
+ lines.push(`OpenAPI: ${title}${version ? ` v${version}` : ''}`);
167
+ if (spec.info?.description) {
168
+ lines.push(`Description: ${spec.info.description.substring(0, 200)}`);
169
+ }
170
+ // Servers
171
+ if (spec.servers?.length) {
172
+ lines.push(`Base URL: ${spec.servers[0].url}`);
173
+ }
174
+ // Auth
175
+ const securitySchemes = spec.components?.securitySchemes;
176
+ if (securitySchemes) {
177
+ const schemes = Object.entries(securitySchemes).map(([name, s]) => {
178
+ if (s.type === 'http')
179
+ return `${name}: ${s.scheme} ${s.type}`;
180
+ if (s.type === 'apiKey')
181
+ return `${name}: API key in ${s.in}`;
182
+ if (s.type === 'oauth2')
183
+ return `${name}: OAuth2`;
184
+ return `${name}: ${s.type}`;
185
+ });
186
+ lines.push(`Auth: ${schemes.join(', ')}`);
187
+ }
188
+ // Endpoints
189
+ if (spec.paths) {
190
+ lines.push('');
191
+ lines.push('Endpoints:');
192
+ for (const [path, methods] of Object.entries(spec.paths)) {
193
+ for (const [method, op] of Object.entries(methods)) {
194
+ if (['get', 'post', 'put', 'patch', 'delete'].includes(method)) {
195
+ const operation = op;
196
+ const summary = operation.summary || operation.operationId || '';
197
+ lines.push(` ${method.toUpperCase()} ${path}${summary ? ` - ${summary}` : ''}`);
198
+ }
199
+ }
200
+ }
201
+ }
202
+ // Schemas
203
+ const schemas = spec.components?.schemas || spec.definitions;
204
+ if (schemas) {
205
+ lines.push('');
206
+ lines.push('Schemas:');
207
+ for (const [name, schema] of Object.entries(schemas)) {
208
+ const s = schema;
209
+ const props = s.properties ? Object.entries(s.properties).map(([k, v]) => `${k} (${v.type || 'object'})`).join(', ') : '';
210
+ lines.push(` ${name}${props ? `: ${props}` : ''}`);
211
+ }
212
+ }
213
+ const content = lines.join('\n').substring(0, MAX_OPENAPI_CHARS);
214
+ return {
215
+ title: `API: ${title}`,
216
+ content,
217
+ file_type: 'openapi',
218
+ content_category: 'architecture',
219
+ tags: ['openapi', 'api'],
220
+ word_count: content.split(/\s+/).length,
221
+ };
222
+ }
223
+ function parseCodeSurface(raw, filename, lang) {
224
+ const lines = raw.split('\n');
225
+ const surface = [];
226
+ surface.push(`// File: ${filename}`);
227
+ surface.push(`// Language: ${lang}`);
228
+ surface.push('');
229
+ let insideBlock = false;
230
+ let braceDepth = 0;
231
+ for (const line of lines) {
232
+ const trimmed = line.trim();
233
+ if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*'))
234
+ continue;
235
+ // Track if we're capturing a multi-line interface/type body
236
+ if (insideBlock) {
237
+ surface.push(line);
238
+ braceDepth += (line.match(/\{/g) || []).length;
239
+ braceDepth -= (line.match(/\}/g) || []).length;
240
+ if (braceDepth <= 0) {
241
+ insideBlock = false;
242
+ braceDepth = 0;
243
+ }
244
+ continue;
245
+ }
246
+ if (lang === 'typescript') {
247
+ // Imports
248
+ if (trimmed.startsWith('import ')) {
249
+ surface.push(trimmed);
250
+ continue;
251
+ }
252
+ // Export statements, interfaces, types, classes, functions, enums
253
+ if (/^export\s/.test(trimmed) || /^(interface|type|class|enum|abstract)\s/.test(trimmed)) {
254
+ surface.push(trimmed);
255
+ // If line opens a block, capture the full body (for interfaces/types)
256
+ if (trimmed.includes('{') && !trimmed.includes('}')) {
257
+ insideBlock = true;
258
+ braceDepth = (trimmed.match(/\{/g) || []).length - (trimmed.match(/\}/g) || []).length;
259
+ }
260
+ continue;
261
+ }
262
+ }
263
+ else if (lang === 'python') {
264
+ if (/^(from |import )/.test(trimmed)) {
265
+ surface.push(trimmed);
266
+ continue;
267
+ }
268
+ if (/^(def |class |async def )/.test(trimmed)) {
269
+ surface.push(trimmed);
270
+ continue;
271
+ }
272
+ // Decorators
273
+ if (trimmed.startsWith('@')) {
274
+ surface.push(trimmed);
275
+ continue;
276
+ }
277
+ }
278
+ else if (lang === 'go') {
279
+ if (/^package\s/.test(trimmed)) {
280
+ surface.push(trimmed);
281
+ continue;
282
+ }
283
+ if (/^import\s/.test(trimmed) || trimmed === 'import (') {
284
+ surface.push(trimmed);
285
+ if (trimmed.includes('(') && !trimmed.includes(')')) {
286
+ insideBlock = true;
287
+ braceDepth = 1;
288
+ }
289
+ continue;
290
+ }
291
+ if (/^(func |type |var |const )/.test(trimmed)) {
292
+ surface.push(trimmed);
293
+ if (trimmed.includes('{') && !trimmed.includes('}')) {
294
+ // For func, just capture the signature, not the body
295
+ if (trimmed.startsWith('func '))
296
+ continue;
297
+ insideBlock = true;
298
+ braceDepth = (trimmed.match(/\{/g) || []).length - (trimmed.match(/\}/g) || []).length;
299
+ }
300
+ continue;
301
+ }
302
+ }
303
+ else if (lang === 'rust') {
304
+ if (/^(pub |use |mod |fn |struct |enum |trait |impl |type |const )/.test(trimmed)) {
305
+ surface.push(trimmed);
306
+ continue;
307
+ }
308
+ }
309
+ else if (lang === 'java') {
310
+ if (/^(import |package )/.test(trimmed)) {
311
+ surface.push(trimmed);
312
+ continue;
313
+ }
314
+ if (/^(public |private |protected |static |abstract |interface |class |enum |@)/.test(trimmed)) {
315
+ surface.push(trimmed);
316
+ continue;
317
+ }
318
+ }
319
+ }
320
+ const content = surface.join('\n').substring(0, MAX_CODE_CHARS);
321
+ return {
322
+ title: filename,
323
+ content: content || `// File: ${filename}\n// No public surface detected`,
324
+ file_type: lang,
325
+ content_category: 'code_snippet',
326
+ tags: [lang, 'code'],
327
+ word_count: content.split(/\s+/).length,
328
+ };
329
+ }
@@ -12,6 +12,9 @@ export interface ProjectProfileData {
12
12
  recent_commits: string[];
13
13
  tech_stack: string[];
14
14
  git_remote_url: string | null;
15
+ entry_points: string | null;
16
+ recent_prs: string | null;
17
+ claude_md_rules: string[];
15
18
  }
16
19
  /**
17
20
  * Gather rich project data from the local filesystem for AI profile generation.
@@ -10,14 +10,18 @@ const GIT_TIMEOUT = 2000;
10
10
  * Every operation is individually try/caught — one failure never blocks the rest.
11
11
  */
12
12
  export function gatherProjectProfile(cwd) {
13
+ const claudeMd = readFileSafe(join(cwd, 'CLAUDE.md'), MAX_FILE_CHARS);
13
14
  return {
14
- claude_md: readFileSafe(join(cwd, 'CLAUDE.md'), MAX_FILE_CHARS),
15
+ claude_md: claudeMd,
15
16
  readme: readFileSafe(join(cwd, 'README.md'), MAX_FILE_CHARS),
16
17
  package_json_subset: readPackageJson(cwd),
17
18
  directory_structure: buildDirectoryTree(cwd),
18
19
  recent_commits: getRecentCommits(cwd),
19
20
  tech_stack: detectTechStack(cwd),
20
21
  git_remote_url: getGitRemoteUrl(cwd),
22
+ entry_points: scanEntryPoints(cwd),
23
+ recent_prs: getRecentPRs(cwd),
24
+ claude_md_rules: extractClaudeMdRules(claudeMd),
21
25
  };
22
26
  }
23
27
  function readFileSafe(path, maxChars) {
@@ -114,6 +118,107 @@ function buildDirectoryTree(cwd) {
114
118
  }
115
119
  return lines.join('\n') || '(empty)';
116
120
  }
121
+ /**
122
+ * Scan entry point files for architecture context.
123
+ * Reads the first 80 lines of key files (index.ts, app.ts, routes/, etc.)
124
+ */
125
+ function scanEntryPoints(cwd) {
126
+ const candidates = [
127
+ 'src/index.ts', 'src/index.js', 'src/app.ts', 'src/app.js',
128
+ 'src/main.ts', 'src/main.js', 'app.ts', 'index.ts',
129
+ 'src/server.ts', 'src/server.js',
130
+ 'pages/_app.tsx', 'app/layout.tsx', 'app/page.tsx',
131
+ 'src/App.tsx', 'src/App.jsx',
132
+ ];
133
+ const found = [];
134
+ for (const candidate of candidates) {
135
+ const fullPath = join(cwd, candidate);
136
+ if (existsSync(fullPath)) {
137
+ try {
138
+ const content = readFileSync(fullPath, 'utf-8');
139
+ const lines = content.split('\n').slice(0, 80);
140
+ // Extract imports and exports for architecture understanding
141
+ const importExportLines = lines.filter(l => l.trim().startsWith('import ') || l.trim().startsWith('export ') ||
142
+ l.trim().startsWith('app.') || l.trim().startsWith('router.') ||
143
+ l.trim().startsWith('const app') || l.trim().startsWith('const server'));
144
+ if (importExportLines.length > 0) {
145
+ found.push(`--- ${candidate} ---\n${importExportLines.join('\n')}`);
146
+ }
147
+ }
148
+ catch { /* skip */ }
149
+ }
150
+ if (found.length >= 3)
151
+ break; // Cap at 3 entry points
152
+ }
153
+ // Also scan for route definitions
154
+ const routeDirs = ['src/routes', 'src/pages', 'src/api', 'app/api', 'routes'];
155
+ for (const routeDir of routeDirs) {
156
+ const fullDir = join(cwd, routeDir);
157
+ if (existsSync(fullDir)) {
158
+ try {
159
+ const files = readdirSync(fullDir).filter(f => !f.startsWith('.')).slice(0, 15);
160
+ found.push(`--- ${routeDir}/ ---\n${files.join(', ')}`);
161
+ }
162
+ catch { /* skip */ }
163
+ break;
164
+ }
165
+ }
166
+ return found.length > 0 ? found.join('\n\n').substring(0, 2000) : null;
167
+ }
168
+ /**
169
+ * Get recent merged PR titles + descriptions via gh CLI.
170
+ * Rich "why" context that commit messages lack.
171
+ */
172
+ function getRecentPRs(cwd) {
173
+ try {
174
+ // Check if gh is available
175
+ execSync('gh auth status', { encoding: 'utf-8', stdio: 'pipe', timeout: 3000 });
176
+ const result = execSync('gh pr list --state merged --limit 10 --json title,body,mergedAt --jq ".[] | \\"- \\(.title)\\" + if .body != \\"\\" then \\"\\n \\(.body | split(\\"\\\\n\\") | first)\\" else \\"\\" end"', { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }).trim();
177
+ return result || null;
178
+ }
179
+ catch {
180
+ // gh not available or not in a GitHub repo - try simpler approach
181
+ try {
182
+ const result = execSync('gh pr list --state merged --limit 10 --json title --jq ".[].title"', { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }).trim();
183
+ return result ? result.split('\n').map(t => `- ${t}`).join('\n') : null;
184
+ }
185
+ catch {
186
+ return null;
187
+ }
188
+ }
189
+ }
190
+ /**
191
+ * Extract actionable rules from CLAUDE.md content.
192
+ * Looks for bullet points, numbered lists, and imperative statements.
193
+ */
194
+ function extractClaudeMdRules(claudeMd) {
195
+ if (!claudeMd)
196
+ return [];
197
+ const rules = [];
198
+ const lines = claudeMd.split('\n');
199
+ for (const line of lines) {
200
+ const trimmed = line.trim();
201
+ // Skip headers, empty lines, code blocks, tables
202
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('```') ||
203
+ trimmed.startsWith('|') || trimmed.startsWith('---'))
204
+ continue;
205
+ // Look for rule-like patterns
206
+ const isRule =
207
+ // Bullet/numbered items with imperative verbs
208
+ (/^[-*]\s+(Always|Never|Don't|Do not|Must|Prefer|Avoid|Use|Keep|Follow|Ensure)/i.test(trimmed)) ||
209
+ (/^\d+\.\s+(Always|Never|Don't|Do not|Must|Prefer|Avoid|Use|Keep|Follow|Ensure)/i.test(trimmed)) ||
210
+ // Lines containing strong conventions
211
+ (/\b(must|always|never|required|forbidden|mandatory)\b/i.test(trimmed) && trimmed.length < 200);
212
+ if (isRule) {
213
+ // Clean up the rule text
214
+ const cleaned = trimmed.replace(/^[-*\d.]+\s*/, '').trim();
215
+ if (cleaned.length > 15 && cleaned.length < 300) {
216
+ rules.push(cleaned);
217
+ }
218
+ }
219
+ }
220
+ return rules.slice(0, 20); // Cap at 20 rules
221
+ }
117
222
  // Duplicated from signal-gatherer.ts to avoid circular imports
118
223
  const KNOWN_TECH = [
119
224
  'react', 'next', 'express', 'fastify', 'vue', 'angular', 'svelte',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@splicr/mcp-server",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Splicr MCP server — route what you read to what you're building",
5
5
  "type": "module",
6
6
  "bin": "./dist/cli.js",
@@ -30,9 +30,11 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@modelcontextprotocol/sdk": "^1.0.0",
33
+ "js-yaml": "^4.1.0",
33
34
  "zod": "^3.23.0"
34
35
  },
35
36
  "devDependencies": {
37
+ "@types/js-yaml": "^4.0.9",
36
38
  "@types/node": "^20.14.0",
37
39
  "tsx": "^4.16.0",
38
40
  "typescript": "^5.5.0"