capy-mcp 1.0.4 → 1.0.6

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/brief.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { detectFramework } from "./framework.js";
2
2
  import { buildProjectFacts } from "./project.js";
3
+ import { buildComponentDiscoveryPlan } from "./component-discovery.js";
3
4
  const SECTION_ORDER = [
4
5
  "Foundations",
5
6
  "Colors",
@@ -17,10 +18,11 @@ const SECTION_ORDER = [
17
18
  export async function buildPreviewBrief(projectRoot, input) {
18
19
  const framework = await detectFramework(projectRoot);
19
20
  const projectFacts = await buildProjectFacts(projectRoot, framework);
20
- const warnings = buildWarnings(framework, input.changedFiles);
21
+ const discoveryPlan = await buildComponentDiscoveryPlan(projectRoot, projectFacts);
22
+ const warnings = buildWarnings(framework, input.changedFiles, discoveryPlan.missingFamilyGaps);
21
23
  return {
22
24
  projectFacts,
23
- inspectionPlan: buildInspectionPlan(projectFacts),
25
+ inspectionPlan: buildInspectionPlan(projectFacts, discoveryPlan),
24
26
  constraints: buildConstraints(framework),
25
27
  deliverableSpec: {
26
28
  goal: input.task === "update_preview"
@@ -41,27 +43,35 @@ export async function buildPreviewBrief(projectRoot, input) {
41
43
  },
42
44
  updateStrategy: buildUpdateStrategy(input.changedFiles),
43
45
  warnings,
44
- instructions: buildInstructions(projectFacts, input),
46
+ instructions: buildInstructions(projectFacts, input, discoveryPlan.instruction),
45
47
  };
46
48
  }
47
- function buildInspectionPlan(projectFacts) {
48
- const appShellTargets = uniqueCompact([
49
- firstMatching(projectFacts.likelyPageDirs, "src/app")
50
- ? "src/app/layout.tsx"
51
- : undefined,
52
- firstMatching(projectFacts.likelyPageDirs, "src/app")
53
- ? "src/app/page.tsx"
54
- : undefined,
55
- firstMatching(projectFacts.likelyPageDirs, "app") ? "app/layout.tsx" : undefined,
56
- firstMatching(projectFacts.likelyPageDirs, "app") ? "app/page.tsx" : undefined,
57
- firstMatching(projectFacts.likelyPageDirs, "src/pages") ? "src/pages/_app.tsx" : undefined,
58
- firstMatching(projectFacts.likelyPageDirs, "pages") ? "pages/_app.tsx" : undefined,
59
- ]);
49
+ function buildInspectionPlan(projectFacts, discoveryPlan) {
50
+ // Build app shell targets dynamically from discovered page dirs
51
+ // Use routing style to suggest the right shell files
52
+ const appShellTargets = [];
53
+ for (const pageDir of projectFacts.likelyPageDirs) {
54
+ if (projectFacts.routingStyle === "app-router") {
55
+ appShellTargets.push(`${pageDir}/layout.tsx`);
56
+ appShellTargets.push(`${pageDir}/page.tsx`);
57
+ }
58
+ else if (projectFacts.routingStyle === "pages-router") {
59
+ appShellTargets.push(`${pageDir}/_app.tsx`);
60
+ }
61
+ else {
62
+ // Unknown or other routing — suggest both
63
+ appShellTargets.push(`${pageDir}/layout.tsx`);
64
+ appShellTargets.push(`${pageDir}/page.tsx`);
65
+ appShellTargets.push(`${pageDir}/_app.tsx`);
66
+ }
67
+ }
68
+ // Deduplicate
69
+ const uniqueShellTargets = Array.from(new Set(appShellTargets));
60
70
  return [
61
71
  {
62
72
  step: 1,
63
73
  action: "Read the app shell and routing entry points first",
64
- targets: appShellTargets.length > 0 ? appShellTargets : projectFacts.likelyPageDirs,
74
+ targets: uniqueShellTargets.length > 0 ? uniqueShellTargets : projectFacts.likelyPageDirs,
65
75
  reason: "Understand the project structure, routing style, and baseline visual language.",
66
76
  },
67
77
  {
@@ -72,12 +82,20 @@ function buildInspectionPlan(projectFacts) {
72
82
  },
73
83
  {
74
84
  step: 3,
75
- action: "Inspect UI/component directories",
76
- targets: projectFacts.likelyComponentDirs.length > 0 ? projectFacts.likelyComponentDirs : projectFacts.likelyUiDirs,
77
- reason: "Find existing primitives, composites, and sections before inventing new preview-only UI.",
85
+ action: "Traverse discovered component directories",
86
+ targets: discoveryPlan.prioritizedFiles.length > 0
87
+ ? discoveryPlan.prioritizedFiles
88
+ : projectFacts.likelyComponentDirs,
89
+ reason: "Read the actual components discovered by Capy's scan. These are real files with PascalCase exports, not guesses.",
78
90
  },
79
91
  {
80
92
  step: 4,
93
+ action: "Inspect UI/component directories for anything the scan missed",
94
+ targets: projectFacts.likelyComponentDirs.length > 0 ? projectFacts.likelyComponentDirs : projectFacts.likelyUiDirs,
95
+ reason: "The automatic scan catches most components, but manually inspect directories for any non-standard patterns that were missed.",
96
+ },
97
+ {
98
+ step: 5,
81
99
  action: "Implement or update the preview route",
82
100
  targets: [projectFacts.previewEntryFile],
83
101
  reason: "Build a neat, scrollable /preview page that reflects the real app structure.",
@@ -90,6 +108,9 @@ function buildConstraints(framework) {
90
108
  "Build a preview page that can support both vertical and horizontal scanning where useful.",
91
109
  "Use horizontal specimen rows only when they make scanning easier.",
92
110
  "Prefer existing components over creating preview-only components.",
111
+ "Traverse all component directories found by Capy. Every file with a PascalCase export is a component candidate. Read real usage examples before marking a preview section complete.",
112
+ "When you only find hooks, providers, or usage patterns, trace one real usage example and mirror that flow in /preview instead of inventing a fake component.",
113
+ "If a component family is not present in the repo, label it as absent rather than fabricating a preview-only substitute.",
93
114
  "Keep the page neat, easy to scan, and aligned with the app's current design language.",
94
115
  "Include an icon inventory when the repo exposes app icons clearly enough to catalogue them.",
95
116
  "Show colors in a uniform swatch format with normalized 6-character hex labels and click-to-copy affordance.",
@@ -113,7 +134,7 @@ function buildUpdateStrategy(changedFiles) {
113
134
  }
114
135
  return strategies;
115
136
  }
116
- function buildWarnings(framework, changedFiles) {
137
+ function buildWarnings(framework, changedFiles, discoveryGaps = []) {
117
138
  const warnings = [];
118
139
  if (framework.confirmationMessage) {
119
140
  warnings.push(framework.confirmationMessage);
@@ -121,18 +142,13 @@ function buildWarnings(framework, changedFiles) {
121
142
  if (!changedFiles || changedFiles.length === 0) {
122
143
  warnings.push("No changedFiles were provided. The agent should inspect git diff or recent edits when performing update_preview.");
123
144
  }
145
+ warnings.push(...discoveryGaps);
124
146
  return warnings;
125
147
  }
126
- function buildInstructions(projectFacts, input) {
148
+ function buildInstructions(projectFacts, input, discoveryInstruction) {
127
149
  const lead = input.task === "update_preview"
128
150
  ? "Update the existing /preview route incrementally."
129
151
  : "Create the /preview route from scratch.";
130
152
  const userGoal = input.userGoal ? ` User goal: ${input.userGoal}.` : "";
131
- return `${lead}${userGoal} Read the app shell first, then global styles, then component directories. After that, implement ${projectFacts.previewEntryFile} as a clean preview surface that supports both vertical and horizontal scanning when useful, includes a dedicated icon section when icons can be discovered, and renders colors as consistent swatches with 6-character hex labels plus click-to-copy behavior using a pointer cursor.`;
132
- }
133
- function uniqueCompact(values) {
134
- return Array.from(new Set(values.filter(Boolean)));
135
- }
136
- function firstMatching(values, needle) {
137
- return values.find((value) => value === needle);
153
+ return `${lead}${userGoal} Read the app shell first, then global styles, then traverse all discovered component directories. After that, implement ${projectFacts.previewEntryFile} as a clean preview surface that supports both vertical and horizontal scanning when useful, includes a dedicated icon section when icons can be discovered, and renders colors as consistent swatches with 6-character hex labels plus click-to-copy behavior using a pointer cursor. ${discoveryInstruction}`;
138
154
  }
@@ -0,0 +1,25 @@
1
+ import type { ProjectFacts } from "./types.js";
2
+ interface DiscoveredComponent {
3
+ path: string;
4
+ basename: string;
5
+ exports: string[];
6
+ contents: string;
7
+ }
8
+ export interface ComponentDiscoveryPlan {
9
+ allDiscoveredComponents: DiscoveredComponent[];
10
+ prioritizedFiles: string[];
11
+ discoveredFamilyFacts: string[];
12
+ missingFamilyGaps: string[];
13
+ instruction: string;
14
+ }
15
+ /**
16
+ * Discover components by scanning the filesystem — no hardcoded family lists.
17
+ *
18
+ * Strategy:
19
+ * 1. Glob for ALL source files within discovered component/UI dirs
20
+ * 2. Extract PascalCase exports from each file
21
+ * 3. Everything with a PascalCase export is a component
22
+ * 4. Classify what we found into loose categories for the agent
23
+ */
24
+ export declare function buildComponentDiscoveryPlan(projectRoot: string, projectFacts: ProjectFacts): Promise<ComponentDiscoveryPlan>;
25
+ export {};
@@ -0,0 +1,144 @@
1
+ import { basename, join } from "path";
2
+ import { glob } from "glob";
3
+ import { readText, toPosixPath } from "./files.js";
4
+ /**
5
+ * Discover components by scanning the filesystem — no hardcoded family lists.
6
+ *
7
+ * Strategy:
8
+ * 1. Glob for ALL source files within discovered component/UI dirs
9
+ * 2. Extract PascalCase exports from each file
10
+ * 3. Everything with a PascalCase export is a component
11
+ * 4. Classify what we found into loose categories for the agent
12
+ */
13
+ export async function buildComponentDiscoveryPlan(projectRoot, projectFacts) {
14
+ const components = await scanForComponents(projectRoot, projectFacts);
15
+ // Group components into loose categories for the agent's benefit
16
+ const prioritizedFiles = components.map((c) => c.path).slice(0, 12);
17
+ const discoveredFamilyFacts = buildDiscoveryFacts(components);
18
+ const missingFamilyGaps = buildGaps(components);
19
+ const instruction = buildInstruction(components, prioritizedFiles);
20
+ return {
21
+ allDiscoveredComponents: components,
22
+ prioritizedFiles,
23
+ discoveredFamilyFacts,
24
+ missingFamilyGaps,
25
+ instruction,
26
+ };
27
+ }
28
+ /**
29
+ * Scan the project for all files that export PascalCase identifiers.
30
+ * Uses the dynamically-discovered component dirs from project.ts,
31
+ * falls back to a broad scan if none were found.
32
+ */
33
+ async function scanForComponents(projectRoot, projectFacts) {
34
+ // Build glob patterns from discovered dirs
35
+ const searchDirs = new Set([
36
+ ...projectFacts.likelyComponentDirs,
37
+ ...projectFacts.likelyUiDirs,
38
+ ]);
39
+ let patterns;
40
+ if (searchDirs.size > 0) {
41
+ patterns = Array.from(searchDirs).map((dir) => `${dir}/**/*.{ts,tsx,js,jsx}`);
42
+ }
43
+ else {
44
+ // Fallback: scan everything if no dirs were discovered
45
+ patterns = ["**/*.{ts,tsx,js,jsx}"];
46
+ }
47
+ const sourceFiles = await glob(patterns, {
48
+ cwd: projectRoot,
49
+ nodir: true,
50
+ ignore: [
51
+ "**/*.test.*",
52
+ "**/*.spec.*",
53
+ "**/*.stories.*",
54
+ "**/*.story.*",
55
+ "**/node_modules/**",
56
+ "**/.next/**",
57
+ "**/dist/**",
58
+ "**/build/**",
59
+ "**/coverage/**",
60
+ "**/.git/**",
61
+ "**/.capy/**",
62
+ "**/preview/**",
63
+ ],
64
+ });
65
+ const components = [];
66
+ for (const file of sourceFiles.map((f) => toPosixPath(f)).sort()) {
67
+ const contents = (await readText(join(projectRoot, file))) ?? "";
68
+ const exports = extractExportCandidates(contents);
69
+ // A file is a component if it has at least one PascalCase export
70
+ if (exports.length > 0) {
71
+ components.push({
72
+ path: file,
73
+ basename: basename(file).replace(/\.[^.]+$/, ""),
74
+ exports,
75
+ contents,
76
+ });
77
+ }
78
+ }
79
+ return components;
80
+ }
81
+ /**
82
+ * Build discovery facts — a short summary of what was found, organized
83
+ * by directory structure rather than hardcoded families.
84
+ */
85
+ function buildDiscoveryFacts(components) {
86
+ if (components.length === 0)
87
+ return [];
88
+ // Group by directory
89
+ const byDir = new Map();
90
+ for (const comp of components) {
91
+ const slashIndex = comp.path.lastIndexOf("/");
92
+ const dir = slashIndex === -1 ? "." : comp.path.slice(0, slashIndex);
93
+ if (!byDir.has(dir))
94
+ byDir.set(dir, []);
95
+ byDir.get(dir).push(comp.exports[0] ?? comp.basename);
96
+ }
97
+ const facts = [];
98
+ for (const [dir, names] of byDir) {
99
+ const listed = names.slice(0, 5).join(", ");
100
+ const suffix = names.length > 5 ? ` and ${names.length - 5} more` : "";
101
+ facts.push(`${dir}/: ${listed}${suffix}.`);
102
+ }
103
+ return facts.slice(0, 8);
104
+ }
105
+ /**
106
+ * Build gap notes — only warn about genuinely concerning gaps,
107
+ * not prescriptive "you should have inputs" type warnings.
108
+ */
109
+ function buildGaps(components) {
110
+ const gaps = [];
111
+ if (components.length === 0) {
112
+ gaps.push("No components were discovered. The scanner found no files with PascalCase exports in the project. You should manually search src/, components/, ui/, features/, lib/, or similar directories to find UI code.");
113
+ }
114
+ return gaps;
115
+ }
116
+ /**
117
+ * Build the instruction string for the agent — tell it how to traverse
118
+ * and find things rather than listing specific things to look for.
119
+ */
120
+ function buildInstruction(components, prioritizedFiles) {
121
+ const traversalGuide = "Traverse the repo's component directories. Every file that exports a PascalCase identifier is a component. Read real usage of each component before building /preview sections. If a component looks like a page section, group it under 'Feature or Page Sections'. If it is a small reusable primitive, group it under the appropriate UI category.";
122
+ const usageGuide = "Use actual component files and real usage examples. If you only find hooks or providers, trace one real usage path and mirror that behavior in /preview instead of inventing a fake specimen.";
123
+ const placementGuide = "Place every component you find into the preview layout based on its actual role. If something does not fit a standard category, create a section for it. Do not invent components that don't exist in the repo, and do not omit components that do.";
124
+ if (prioritizedFiles.length === 0) {
125
+ return `${traversalGuide} ${usageGuide} ${placementGuide}`;
126
+ }
127
+ return `${traversalGuide} Start with ${prioritizedFiles.slice(0, 6).join(", ")}. ${usageGuide} ${placementGuide}`;
128
+ }
129
+ function extractExportCandidates(contents) {
130
+ const exportNames = new Set();
131
+ const patterns = [
132
+ /export\s+function\s+([A-Z][A-Za-z0-9_]*)/g,
133
+ /export\s+const\s+([A-Z][A-Za-z0-9_]*)/g,
134
+ /export\s+class\s+([A-Z][A-Za-z0-9_]*)/g,
135
+ /export\s+default\s+function\s+([A-Z][A-Za-z0-9_]*)/g,
136
+ ];
137
+ for (const pattern of patterns) {
138
+ let match;
139
+ while ((match = pattern.exec(contents)) !== null) {
140
+ exportNames.add(match[1]);
141
+ }
142
+ }
143
+ return Array.from(exportNames);
144
+ }
@@ -1,6 +1,7 @@
1
1
  import { glob } from "glob";
2
2
  import { basename, join } from "path";
3
3
  import { buildPreviewBrief } from "./brief.js";
4
+ import { buildComponentDiscoveryPlan } from "./component-discovery.js";
4
5
  import { readText, toPosixPath, writeText } from "./files.js";
5
6
  import { detectFramework } from "./framework.js";
6
7
  import { buildProjectFacts } from "./project.js";
@@ -8,6 +9,7 @@ const DEFAULT_ARTIFACT_PATH = ".capy/design-system.json";
8
9
  export async function buildDesignSystemArtifact(projectRoot, input = {}) {
9
10
  const framework = await detectFramework(projectRoot);
10
11
  const projectFacts = await buildProjectFacts(projectRoot, framework);
12
+ const discoveryPlan = await buildComponentDiscoveryPlan(projectRoot, projectFacts);
11
13
  const previewBrief = await buildPreviewBrief(projectRoot, {
12
14
  task: input.mode === "update" ? "update_preview" : "build_preview",
13
15
  changedFiles: input.changedFiles,
@@ -15,9 +17,9 @@ export async function buildDesignSystemArtifact(projectRoot, input = {}) {
15
17
  });
16
18
  const cssVariables = await collectCssVariables(projectRoot, projectFacts.likelyStyleFiles);
17
19
  const components = await collectComponents(projectRoot, projectFacts);
18
- const readFirst = buildReadFirst(previewBrief, components);
20
+ const readFirst = buildReadFirst(previewBrief, components, discoveryPlan.prioritizedFiles);
19
21
  const bridges = await buildUsageBridges(projectRoot, projectFacts.likelyStyleFiles, components);
20
- const gaps = buildGapNotes(projectFacts, components, cssVariables);
22
+ const gaps = buildGapNotes(projectFacts, components, cssVariables, discoveryPlan.missingFamilyGaps);
21
23
  const artifactPath = input.artifactPath ?? DEFAULT_ARTIFACT_PATH;
22
24
  return {
23
25
  forAgent: {
@@ -25,7 +27,7 @@ export async function buildDesignSystemArtifact(projectRoot, input = {}) {
25
27
  ? "Update /preview incrementally. Read the files below first, then touch only the sections affected by the changed files unless shared foundations changed."
26
28
  : "Build or refine /preview. Read the files below first, then mirror the repo's existing UI language instead of inventing a new one.",
27
29
  readFirst,
28
- facts: buildAgentFacts(projectFacts, components, cssVariables),
30
+ facts: buildAgentFacts(projectFacts, components, cssVariables, discoveryPlan.discoveredFamilyFacts),
29
31
  bridges,
30
32
  gaps,
31
33
  updateHints: previewBrief.updateStrategy,
@@ -168,7 +170,7 @@ function classifyCssVariable(name) {
168
170
  function lineNumberAt(contents, index) {
169
171
  return contents.slice(0, index).split("\n").length;
170
172
  }
171
- function buildReadFirst(previewBrief, components) {
173
+ function buildReadFirst(previewBrief, components, prioritizedFiles) {
172
174
  const seen = new Set();
173
175
  const readFirst = [];
174
176
  const firstComponentByDir = new Map();
@@ -181,8 +183,39 @@ function buildReadFirst(previewBrief, components) {
181
183
  firstComponentByDir.set(directory, component.path);
182
184
  }
183
185
  }
184
- for (const step of previewBrief.inspectionPlan) {
186
+ for (const step of previewBrief.inspectionPlan.slice(0, 2)) {
185
187
  for (const target of step.targets) {
188
+ if (!looksLikeProjectPath(target))
189
+ continue;
190
+ const normalizedTarget = firstComponentByDir.get(target) ?? target;
191
+ if (seen.has(normalizedTarget))
192
+ continue;
193
+ seen.add(normalizedTarget);
194
+ readFirst.push({
195
+ path: normalizedTarget,
196
+ reason: step.reason,
197
+ });
198
+ if (readFirst.length >= 8) {
199
+ return readFirst;
200
+ }
201
+ }
202
+ }
203
+ for (const prioritizedFile of prioritizedFiles) {
204
+ if (seen.has(prioritizedFile))
205
+ continue;
206
+ seen.add(prioritizedFile);
207
+ readFirst.push({
208
+ path: prioritizedFile,
209
+ reason: "Likely reusable primitive or usage example. Read this before inventing preview-only specimens.",
210
+ });
211
+ if (readFirst.length >= 8) {
212
+ return readFirst;
213
+ }
214
+ }
215
+ for (const step of previewBrief.inspectionPlan.slice(2)) {
216
+ for (const target of step.targets) {
217
+ if (!looksLikeProjectPath(target))
218
+ continue;
186
219
  const normalizedTarget = firstComponentByDir.get(target) ?? target;
187
220
  if (seen.has(normalizedTarget))
188
221
  continue;
@@ -198,16 +231,17 @@ function buildReadFirst(previewBrief, components) {
198
231
  }
199
232
  return readFirst;
200
233
  }
201
- function buildAgentFacts(projectFacts, components, cssVariables) {
234
+ function buildAgentFacts(projectFacts, components, cssVariables, discoveredFamilyFacts) {
202
235
  return [
203
236
  `Use ${projectFacts.framework} conventions.`,
204
237
  `Implement /preview at ${projectFacts.previewEntryFile}.`,
205
238
  "Use repo-relative paths only.",
206
239
  `Found ${components.length} component candidates and ${cssVariables.length} CSS variables.`,
240
+ ...discoveredFamilyFacts,
207
241
  ];
208
242
  }
209
- function buildGapNotes(projectFacts, components, cssVariables) {
210
- const gaps = [];
243
+ function buildGapNotes(projectFacts, components, cssVariables, discoveryGaps) {
244
+ const gaps = [...discoveryGaps];
211
245
  if (components.length === 0) {
212
246
  gaps.push("No components were detected. Grep src/components, src/ui, src/features, components, ui, or features before assuming the app has no reusable UI.");
213
247
  }
@@ -261,3 +295,6 @@ function normalizeHex(hex) {
261
295
  }
262
296
  return `#${normalized}`;
263
297
  }
298
+ function looksLikeProjectPath(target) {
299
+ return target.includes("/") || /^[A-Za-z0-9._-]+\.(?:tsx|ts|jsx|js|css|scss|sass|less)$/.test(target);
300
+ }
package/dist/project.d.ts CHANGED
@@ -1,2 +1,9 @@
1
1
  import type { FrameworkInfo, ProjectFacts } from "./types.js";
2
+ /**
3
+ * Build project facts by actually scanning the filesystem instead of
4
+ * checking a hardcoded list of candidate directories.
5
+ *
6
+ * Strategy: glob for all source files, read their exports, then classify
7
+ * directories by what they contain rather than what they're named.
8
+ */
2
9
  export declare function buildProjectFacts(projectRoot: string, framework: FrameworkInfo): Promise<ProjectFacts>;
package/dist/project.js CHANGED
@@ -1,24 +1,40 @@
1
+ import { basename, dirname } from "path";
1
2
  import { glob } from "glob";
2
- import { join } from "path";
3
- import { fileExists, toPosixPath } from "./files.js";
3
+ import { readText, toPosixPath } from "./files.js";
4
+ /**
5
+ * Build project facts by actually scanning the filesystem instead of
6
+ * checking a hardcoded list of candidate directories.
7
+ *
8
+ * Strategy: glob for all source files, read their exports, then classify
9
+ * directories by what they contain rather than what they're named.
10
+ */
4
11
  export async function buildProjectFacts(projectRoot, framework) {
5
- const componentDirs = await findExistingDirectories(projectRoot, [
6
- "src/components",
7
- "src/ui",
8
- "src/features",
9
- "components",
10
- "ui",
11
- "features",
12
- ]);
13
- const pageDirs = await findExistingDirectories(projectRoot, [
14
- "src/app",
15
- "app",
16
- "src/pages",
17
- "pages",
18
- "src/routes",
19
- "routes",
20
- ]);
12
+ const sourceFiles = await glob(["**/*.{ts,tsx,js,jsx}"], {
13
+ cwd: projectRoot,
14
+ nodir: true,
15
+ ignore: [
16
+ "**/*.test.*",
17
+ "**/*.spec.*",
18
+ "**/*.stories.*",
19
+ "**/*.story.*",
20
+ "**/node_modules/**",
21
+ "**/.next/**",
22
+ "**/dist/**",
23
+ "**/build/**",
24
+ "**/coverage/**",
25
+ "**/.git/**",
26
+ "**/.capy/**",
27
+ "**/preview/**",
28
+ ],
29
+ });
30
+ const normalized = sourceFiles.map((f) => toPosixPath(f));
31
+ // Scan files for PascalCase exports to find component directories
32
+ const componentDirs = await findComponentDirs(projectRoot, normalized);
33
+ // Find page/route directories by looking for routing markers
34
+ const pageDirs = findPageDirs(normalized);
35
+ // Find style files — already dynamic via glob
21
36
  const styleFiles = await findStyleFiles(projectRoot);
37
+ // UI dirs = union of component + page dirs
22
38
  const likelyUiDirs = Array.from(new Set([...componentDirs, ...pageDirs.filter((dir) => !dir.endsWith("/preview"))]));
23
39
  return {
24
40
  framework: framework.kind,
@@ -32,14 +48,92 @@ export async function buildProjectFacts(projectRoot, framework) {
32
48
  likelyUiDirs,
33
49
  };
34
50
  }
35
- async function findExistingDirectories(projectRoot, candidates) {
36
- const results = [];
37
- for (const candidate of candidates) {
38
- if (await fileExists(join(projectRoot, candidate))) {
39
- results.push(candidate);
51
+ /**
52
+ * Find component directories by scanning source files for PascalCase exports.
53
+ * A directory is a "component dir" if it contains ≥1 file with a PascalCase export.
54
+ * We deduplicate to the shallowest ancestor that qualifies.
55
+ */
56
+ async function findComponentDirs(projectRoot, sourceFiles) {
57
+ const dirComponentCount = new Map();
58
+ for (const file of sourceFiles) {
59
+ // Skip files at the root level (no directory)
60
+ if (!file.includes("/"))
61
+ continue;
62
+ const ext = file.split(".").pop() ?? "";
63
+ // Only check .tsx and .jsx files — those are almost always components
64
+ // Also check .ts/.js but require a PascalCase filename as extra signal
65
+ const isTsxJsx = ext === "tsx" || ext === "jsx";
66
+ const fileName = basename(file).replace(/\.[^.]+$/, "");
67
+ const isPascalFileName = /^[A-Z][a-zA-Z0-9]*$/.test(fileName);
68
+ if (!isTsxJsx && !isPascalFileName)
69
+ continue;
70
+ const contents = await readText(`${projectRoot}/${file}`);
71
+ if (!contents)
72
+ continue;
73
+ if (hasPascalCaseExport(contents)) {
74
+ const dir = dirname(file);
75
+ dirComponentCount.set(dir, (dirComponentCount.get(dir) ?? 0) + 1);
76
+ }
77
+ }
78
+ if (dirComponentCount.size === 0)
79
+ return [];
80
+ // Collect all dirs that have components
81
+ const allDirs = Array.from(dirComponentCount.keys()).sort();
82
+ // Deduplicate: if both "src/components" and "src/components/sections" exist,
83
+ // keep the parent "src/components" (the child is already covered by glob patterns).
84
+ // But also keep the child if the parent has 0 direct components (e.g. parent is
85
+ // just an organizer directory).
86
+ const roots = deduplicateToRoots(allDirs);
87
+ return roots;
88
+ }
89
+ /**
90
+ * Given a sorted list of directory paths, return the root-most directories.
91
+ * "src/components/sections" is dropped if "src/components" is in the list.
92
+ */
93
+ function deduplicateToRoots(sortedDirs) {
94
+ const roots = [];
95
+ for (const dir of sortedDirs) {
96
+ const isChildOfExisting = roots.some((root) => dir.startsWith(root + "/"));
97
+ if (!isChildOfExisting) {
98
+ roots.push(dir);
40
99
  }
41
100
  }
42
- return results;
101
+ return roots;
102
+ }
103
+ /**
104
+ * Find page/route directories by looking for routing convention markers
105
+ * in the actual files instead of checking hardcoded paths.
106
+ */
107
+ function findPageDirs(sourceFiles) {
108
+ const pageDirSet = new Set();
109
+ // Routing markers that indicate a directory is a page directory
110
+ const routingMarkers = [
111
+ "layout.tsx", "layout.ts", "layout.jsx", "layout.js",
112
+ "page.tsx", "page.ts", "page.jsx", "page.js",
113
+ "_app.tsx", "_app.ts", "_app.jsx", "_app.js",
114
+ "_document.tsx", "_document.ts",
115
+ "+page.svelte", "+layout.svelte",
116
+ ];
117
+ for (const file of sourceFiles) {
118
+ const fileName = basename(file);
119
+ if (routingMarkers.includes(fileName)) {
120
+ const dir = dirname(file);
121
+ // We want the top-level routing root, not every nested route
122
+ // e.g. for "src/app/blog/page.tsx" we want "src/app"
123
+ pageDirSet.add(dir);
124
+ }
125
+ }
126
+ if (pageDirSet.size === 0)
127
+ return [];
128
+ // Find the root-level page dirs (e.g. "src/app" covers "src/app/blog")
129
+ const sorted = Array.from(pageDirSet).sort();
130
+ return deduplicateToRoots(sorted);
131
+ }
132
+ /**
133
+ * Check if file contents contain a PascalCase export (function, const, or class).
134
+ */
135
+ function hasPascalCaseExport(contents) {
136
+ return /export\s+(?:default\s+)?(?:function|const|class)\s+[A-Z][A-Za-z0-9_]*/.test(contents);
43
137
  }
44
138
  async function findStyleFiles(projectRoot) {
45
139
  const files = await glob(["**/*.{css,scss,sass,less}"], {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "capy-mcp",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "MCP server that inspects a repo, returns a structured /preview brief, and writes a design-system JSON artifact for AI coding agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",