@gemini-designer/mcp-server 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.
- package/.prettierrc +9 -0
- package/dist/components/catalog.d.ts +24 -0
- package/dist/components/catalog.d.ts.map +1 -0
- package/dist/components/catalog.js +186 -0
- package/dist/components/catalog.js.map +1 -0
- package/dist/config/index.d.ts +60 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +199 -0
- package/dist/config/index.js.map +1 -0
- package/dist/context/builder.d.ts +32 -0
- package/dist/context/builder.d.ts.map +1 -0
- package/dist/context/builder.js +194 -0
- package/dist/context/builder.js.map +1 -0
- package/dist/context/filter.d.ts +28 -0
- package/dist/context/filter.d.ts.map +1 -0
- package/dist/context/filter.js +136 -0
- package/dist/context/filter.js.map +1 -0
- package/dist/context/grounding.d.ts +27 -0
- package/dist/context/grounding.d.ts.map +1 -0
- package/dist/context/grounding.js +162 -0
- package/dist/context/grounding.js.map +1 -0
- package/dist/context/guards.d.ts +31 -0
- package/dist/context/guards.d.ts.map +1 -0
- package/dist/context/guards.js +76 -0
- package/dist/context/guards.js.map +1 -0
- package/dist/context/repo-hints.d.ts +12 -0
- package/dist/context/repo-hints.d.ts.map +1 -0
- package/dist/context/repo-hints.js +40 -0
- package/dist/context/repo-hints.js.map +1 -0
- package/dist/generation/gemini-client.d.ts +27 -0
- package/dist/generation/gemini-client.d.ts.map +1 -0
- package/dist/generation/gemini-client.js +64 -0
- package/dist/generation/gemini-client.js.map +1 -0
- package/dist/generation/litellm-client.d.ts +16 -0
- package/dist/generation/litellm-client.d.ts.map +1 -0
- package/dist/generation/litellm-client.js +98 -0
- package/dist/generation/litellm-client.js.map +1 -0
- package/dist/generation/remote-client.d.ts +20 -0
- package/dist/generation/remote-client.d.ts.map +1 -0
- package/dist/generation/remote-client.js +69 -0
- package/dist/generation/remote-client.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/output/file-writer.d.ts +39 -0
- package/dist/output/file-writer.d.ts.map +1 -0
- package/dist/output/file-writer.js +153 -0
- package/dist/output/file-writer.js.map +1 -0
- package/dist/output/formatter.d.ts +26 -0
- package/dist/output/formatter.d.ts.map +1 -0
- package/dist/output/formatter.js +156 -0
- package/dist/output/formatter.js.map +1 -0
- package/dist/server.d.ts +9 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +22 -0
- package/dist/server.js.map +1 -0
- package/dist/stack/detect.d.ts +49 -0
- package/dist/stack/detect.d.ts.map +1 -0
- package/dist/stack/detect.js +157 -0
- package/dist/stack/detect.js.map +1 -0
- package/dist/tokens/sync.d.ts +32 -0
- package/dist/tokens/sync.d.ts.map +1 -0
- package/dist/tokens/sync.js +188 -0
- package/dist/tokens/sync.js.map +1 -0
- package/dist/tools/analyze-screenshot-ui.d.ts +18 -0
- package/dist/tools/analyze-screenshot-ui.d.ts.map +1 -0
- package/dist/tools/analyze-screenshot-ui.js +133 -0
- package/dist/tools/analyze-screenshot-ui.js.map +1 -0
- package/dist/tools/analyze-tokens.d.ts +10 -0
- package/dist/tools/analyze-tokens.d.ts.map +1 -0
- package/dist/tools/analyze-tokens.js +107 -0
- package/dist/tools/analyze-tokens.js.map +1 -0
- package/dist/tools/catalog-components.d.ts +14 -0
- package/dist/tools/catalog-components.d.ts.map +1 -0
- package/dist/tools/catalog-components.js +85 -0
- package/dist/tools/catalog-components.js.map +1 -0
- package/dist/tools/create-ui.d.ts +10 -0
- package/dist/tools/create-ui.d.ts.map +1 -0
- package/dist/tools/create-ui.js +167 -0
- package/dist/tools/create-ui.js.map +1 -0
- package/dist/tools/detect-ui-stack.d.ts +15 -0
- package/dist/tools/detect-ui-stack.d.ts.map +1 -0
- package/dist/tools/detect-ui-stack.js +52 -0
- package/dist/tools/detect-ui-stack.js.map +1 -0
- package/dist/tools/generate-component-variants.d.ts +15 -0
- package/dist/tools/generate-component-variants.d.ts.map +1 -0
- package/dist/tools/generate-component-variants.js +199 -0
- package/dist/tools/generate-component-variants.js.map +1 -0
- package/dist/tools/generate-vibes.d.ts +10 -0
- package/dist/tools/generate-vibes.d.ts.map +1 -0
- package/dist/tools/generate-vibes.js +145 -0
- package/dist/tools/generate-vibes.js.map +1 -0
- package/dist/tools/index.d.ts +12 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +36 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/modify-ui.d.ts +11 -0
- package/dist/tools/modify-ui.d.ts.map +1 -0
- package/dist/tools/modify-ui.js +207 -0
- package/dist/tools/modify-ui.js.map +1 -0
- package/dist/tools/scaffold-project.d.ts +10 -0
- package/dist/tools/scaffold-project.d.ts.map +1 -0
- package/dist/tools/scaffold-project.js +122 -0
- package/dist/tools/scaffold-project.js.map +1 -0
- package/dist/tools/snippet-ui.d.ts +11 -0
- package/dist/tools/snippet-ui.d.ts.map +1 -0
- package/dist/tools/snippet-ui.js +194 -0
- package/dist/tools/snippet-ui.js.map +1 -0
- package/dist/tools/sync-design-tokens.d.ts +14 -0
- package/dist/tools/sync-design-tokens.d.ts.map +1 -0
- package/dist/tools/sync-design-tokens.js +233 -0
- package/dist/tools/sync-design-tokens.js.map +1 -0
- package/dist/utils/walk.d.ts +15 -0
- package/dist/utils/walk.d.ts.map +1 -0
- package/dist/utils/walk.js +63 -0
- package/dist/utils/walk.js.map +1 -0
- package/eslint.config.js +37 -0
- package/package.json +56 -0
- package/src/__tests__/builder.test.ts +31 -0
- package/src/__tests__/config.test.ts +52 -0
- package/src/__tests__/filter.test.ts +109 -0
- package/src/components/catalog.ts +214 -0
- package/src/config/index.ts +237 -0
- package/src/context/builder.ts +233 -0
- package/src/context/filter.ts +164 -0
- package/src/context/grounding.ts +191 -0
- package/src/context/guards.ts +94 -0
- package/src/context/repo-hints.ts +43 -0
- package/src/generation/gemini-client.ts +94 -0
- package/src/generation/litellm-client.ts +121 -0
- package/src/generation/remote-client.ts +103 -0
- package/src/index.ts +36 -0
- package/src/output/file-writer.ts +181 -0
- package/src/output/formatter.ts +186 -0
- package/src/server.ts +28 -0
- package/src/stack/detect.ts +204 -0
- package/src/tokens/sync.ts +212 -0
- package/src/tools/analyze-screenshot-ui.ts +150 -0
- package/src/tools/analyze-tokens.ts +123 -0
- package/src/tools/catalog-components.ts +99 -0
- package/src/tools/create-ui.ts +194 -0
- package/src/tools/detect-ui-stack.ts +64 -0
- package/src/tools/generate-component-variants.ts +218 -0
- package/src/tools/generate-vibes.ts +177 -0
- package/src/tools/index.ts +42 -0
- package/src/tools/modify-ui.ts +230 -0
- package/src/tools/scaffold-project.ts +138 -0
- package/src/tools/snippet-ui.ts +222 -0
- package/src/tools/sync-design-tokens.ts +256 -0
- package/src/utils/walk.ts +75 -0
- package/tsconfig.json +34 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Filter
|
|
3
|
+
*
|
|
4
|
+
* Security patterns to prevent sensitive data from being shared.
|
|
5
|
+
* Ensures only UI-relevant files are included in context.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as path from 'node:path';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Files/directories that should NEVER be included in context
|
|
12
|
+
*/
|
|
13
|
+
const SENSITIVE_PATTERNS = [
|
|
14
|
+
// Environment and secrets
|
|
15
|
+
/\.env/i,
|
|
16
|
+
/secrets?\./i,
|
|
17
|
+
/\.pem$/i,
|
|
18
|
+
/\.key$/i,
|
|
19
|
+
/\.crt$/i,
|
|
20
|
+
/credentials/i,
|
|
21
|
+
/\.htpasswd/i,
|
|
22
|
+
|
|
23
|
+
// Private keys and certificates
|
|
24
|
+
/id_rsa/i,
|
|
25
|
+
/id_ed25519/i,
|
|
26
|
+
/\.p12$/i,
|
|
27
|
+
/\.pfx$/i,
|
|
28
|
+
|
|
29
|
+
// Config with potential secrets
|
|
30
|
+
/\.npmrc$/i,
|
|
31
|
+
/\.pypirc$/i,
|
|
32
|
+
/kubeconfig/i,
|
|
33
|
+
/\.docker\/config\.json$/i,
|
|
34
|
+
|
|
35
|
+
// Database
|
|
36
|
+
/\.sqlite$/i,
|
|
37
|
+
/\.db$/i,
|
|
38
|
+
/migrations?\//i,
|
|
39
|
+
/seeds?\//i,
|
|
40
|
+
|
|
41
|
+
// Backend/server code (when isolating UI)
|
|
42
|
+
/\/api\//i,
|
|
43
|
+
/\/server\//i,
|
|
44
|
+
/\/backend\//i,
|
|
45
|
+
/\/functions\//i, // Serverless
|
|
46
|
+
/\/lambda\//i,
|
|
47
|
+
/\/middleware\//i,
|
|
48
|
+
|
|
49
|
+
// Auth-related
|
|
50
|
+
/\/auth\//i,
|
|
51
|
+
/passport/i,
|
|
52
|
+
/jwt/i,
|
|
53
|
+
|
|
54
|
+
// System and dependencies
|
|
55
|
+
/node_modules\//i,
|
|
56
|
+
/\.git\//i,
|
|
57
|
+
/vendor\//i,
|
|
58
|
+
/\.cache\//i,
|
|
59
|
+
/\.next\//i,
|
|
60
|
+
/\.nuxt\//i,
|
|
61
|
+
/dist\//i,
|
|
62
|
+
/build\//i,
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* UI-relevant file patterns (for auto-discovery)
|
|
67
|
+
*/
|
|
68
|
+
export const UI_INCLUDE_PATTERNS = [
|
|
69
|
+
// Stylesheets
|
|
70
|
+
/\.(css|scss|less|sass|styl)$/i,
|
|
71
|
+
|
|
72
|
+
// Components
|
|
73
|
+
/\.(tsx|jsx)$/i,
|
|
74
|
+
/\.(vue|svelte)$/i,
|
|
75
|
+
|
|
76
|
+
// Design tokens
|
|
77
|
+
/theme\./i,
|
|
78
|
+
/tokens?\./i,
|
|
79
|
+
/variables\./i,
|
|
80
|
+
/design[-_]?system/i,
|
|
81
|
+
|
|
82
|
+
// Config files for styling
|
|
83
|
+
/tailwind\.config/i,
|
|
84
|
+
/postcss\.config/i,
|
|
85
|
+
/styled-components/i,
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Check if a file path matches sensitive patterns
|
|
90
|
+
*/
|
|
91
|
+
export function isSensitiveFile(filePath: string): boolean {
|
|
92
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
93
|
+
|
|
94
|
+
for (const pattern of SENSITIVE_PATTERNS) {
|
|
95
|
+
if (pattern.test(normalized)) {
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Check if a file path is within allowed paths
|
|
105
|
+
*/
|
|
106
|
+
export function isPathAllowed(filePath: string, allowedPaths: string[]): boolean {
|
|
107
|
+
const absPath = path.resolve(filePath);
|
|
108
|
+
|
|
109
|
+
for (const allowed of allowedPaths) {
|
|
110
|
+
const absAllowed = path.resolve(allowed);
|
|
111
|
+
if (absPath.startsWith(absAllowed)) {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Check if a file is UI-relevant
|
|
121
|
+
*/
|
|
122
|
+
export function isUIRelevant(filePath: string): boolean {
|
|
123
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
124
|
+
|
|
125
|
+
for (const pattern of UI_INCLUDE_PATTERNS) {
|
|
126
|
+
if (pattern.test(normalized)) {
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Sanitize file content to remove potential secrets
|
|
136
|
+
* This is a best-effort filter for dynamic content
|
|
137
|
+
*/
|
|
138
|
+
export function sanitizeContent(content: string): string {
|
|
139
|
+
// Remove common secret patterns
|
|
140
|
+
const patterns = [
|
|
141
|
+
// API keys (generic patterns)
|
|
142
|
+
/(['"`])?(api[_-]?key|apikey|secret|password|token|auth)(['"`])?[\s]*[:=][\s]*['"`][^'"`]+['"`]/gi,
|
|
143
|
+
|
|
144
|
+
// Bearer tokens
|
|
145
|
+
/Bearer\s+[A-Za-z0-9\-_=]+\.[A-Za-z0-9\-_=]+\.?[A-Za-z0-9\-_.+/=]*/gi,
|
|
146
|
+
|
|
147
|
+
// AWS keys
|
|
148
|
+
/AKIA[0-9A-Z]{16}/g,
|
|
149
|
+
|
|
150
|
+
// Private keys
|
|
151
|
+
/-----BEGIN[\s\w]+PRIVATE KEY-----[\s\S]+?-----END[\s\w]+PRIVATE KEY-----/g,
|
|
152
|
+
|
|
153
|
+
// Connection strings
|
|
154
|
+
/(mongodb|postgresql|mysql|redis):\/\/[^\s'"]+/gi,
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
let result = content;
|
|
158
|
+
|
|
159
|
+
for (const pattern of patterns) {
|
|
160
|
+
result = result.replace(pattern, '[REDACTED]');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* grounding.ts
|
|
3
|
+
*
|
|
4
|
+
* Deterministic “repo grounding” injected into prompts to improve output quality
|
|
5
|
+
* without burning large context windows.
|
|
6
|
+
*
|
|
7
|
+
* This module is used internally by tools like modify_ui and
|
|
8
|
+
* generate_component_variants to:
|
|
9
|
+
* - auto-detect the UI stack (framework/styling/libs)
|
|
10
|
+
* - catalog available components for reuse
|
|
11
|
+
*
|
|
12
|
+
* The output is intentionally concise and stable.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as path from 'node:path';
|
|
16
|
+
import { Config } from '../config/index.js';
|
|
17
|
+
import { detectUiStack, type StackDetectionResult } from '../stack/detect.js';
|
|
18
|
+
import { walkFiles, toPosixPath } from '../utils/walk.js';
|
|
19
|
+
import { buildComponentCatalog, type CatalogResult, type ComponentExport } from '../components/catalog.js';
|
|
20
|
+
|
|
21
|
+
type Cached = {
|
|
22
|
+
ts: number;
|
|
23
|
+
stack: StackDetectionResult;
|
|
24
|
+
catalog: CatalogResult;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const CACHE_TTL_MS = 60_000;
|
|
28
|
+
const cache = new Map<string, Cached>();
|
|
29
|
+
|
|
30
|
+
function resolveRootForFile(absFile: string | null, config: Config): string {
|
|
31
|
+
const allowed = (config.allowedPaths || []).map((p) => path.resolve(p));
|
|
32
|
+
const fallback = allowed[0] || process.cwd();
|
|
33
|
+
if (!absFile) return fallback;
|
|
34
|
+
|
|
35
|
+
const abs = path.resolve(absFile);
|
|
36
|
+
|
|
37
|
+
// pick the deepest allowed root that contains the file
|
|
38
|
+
const matches = allowed
|
|
39
|
+
.filter((root) => abs === root || abs.startsWith(root + path.sep))
|
|
40
|
+
.sort((a, b) => b.length - a.length);
|
|
41
|
+
|
|
42
|
+
return matches[0] || fallback;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function summarizeStack(stack: StackDetectionResult): string {
|
|
46
|
+
const summary = {
|
|
47
|
+
framework: stack.framework,
|
|
48
|
+
typescript: stack.language.typescript,
|
|
49
|
+
styling: stack.styling,
|
|
50
|
+
uiLibraries: stack.uiLibraries,
|
|
51
|
+
iconLibraries: stack.iconLibraries,
|
|
52
|
+
tooling: stack.tooling,
|
|
53
|
+
conventions: {
|
|
54
|
+
srcDir: stack.conventions.srcDir,
|
|
55
|
+
hasAppDir: stack.conventions.hasAppDir,
|
|
56
|
+
hasPagesDir: stack.conventions.hasPagesDir,
|
|
57
|
+
tsconfigPaths: stack.conventions.tsconfigPaths ? Object.keys(stack.conventions.tsconfigPaths) : undefined,
|
|
58
|
+
},
|
|
59
|
+
files: {
|
|
60
|
+
tailwindConfig: stack.files.tailwindConfig,
|
|
61
|
+
componentsJson: stack.files.componentsJson,
|
|
62
|
+
storybookDir: stack.files.storybookDir,
|
|
63
|
+
},
|
|
64
|
+
warnings: stack.warnings,
|
|
65
|
+
};
|
|
66
|
+
return JSON.stringify(summary, null, 2);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function tokenizeInstruction(instruction?: string): Set<string> {
|
|
70
|
+
const set = new Set<string>();
|
|
71
|
+
if (!instruction) return set;
|
|
72
|
+
const tokens = instruction.match(/[A-Za-z_][A-Za-z0-9_]*/g) || [];
|
|
73
|
+
for (const t of tokens) set.add(t.toLowerCase());
|
|
74
|
+
return set;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function scoreComponent(
|
|
78
|
+
c: ComponentExport,
|
|
79
|
+
focusDirRel: string | null,
|
|
80
|
+
instructionTokens: Set<string>
|
|
81
|
+
): number {
|
|
82
|
+
let score = 0;
|
|
83
|
+
|
|
84
|
+
// Mentioned explicitly in instruction
|
|
85
|
+
if (instructionTokens.has(c.name.toLowerCase())) score += 8;
|
|
86
|
+
|
|
87
|
+
// Same directory as focused file
|
|
88
|
+
if (focusDirRel && c.file.startsWith(focusDirRel + '/')) score += 6;
|
|
89
|
+
|
|
90
|
+
// Common reusable directories
|
|
91
|
+
if (
|
|
92
|
+
c.file.includes('/components/') ||
|
|
93
|
+
c.file.startsWith('components/') ||
|
|
94
|
+
c.file.includes('/ui/') ||
|
|
95
|
+
c.file.includes('/shared/')
|
|
96
|
+
) {
|
|
97
|
+
score += 3;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Prefer TSX
|
|
101
|
+
if (c.file.endsWith('.tsx')) score += 1;
|
|
102
|
+
|
|
103
|
+
return score;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function formatCatalogSubset(catalog: CatalogResult, subset: ComponentExport[]): string {
|
|
107
|
+
const lines: string[] = [];
|
|
108
|
+
const header = `scanned_files=${catalog.filesScanned}, total_exports=${catalog.components.length}`;
|
|
109
|
+
lines.push(header);
|
|
110
|
+
if (catalog.warnings.length) {
|
|
111
|
+
lines.push(`warnings: ${catalog.warnings.join(' | ')}`);
|
|
112
|
+
}
|
|
113
|
+
lines.push('');
|
|
114
|
+
lines.push('components:');
|
|
115
|
+
for (const c of subset) {
|
|
116
|
+
const extras: string[] = [];
|
|
117
|
+
if (c.exportType) extras.push(c.exportType);
|
|
118
|
+
if (c.propsType) extras.push(`props: ${c.propsType}`);
|
|
119
|
+
if (c.jsDoc) extras.push(`doc: ${c.jsDoc}`);
|
|
120
|
+
const extra = extras.length ? ` (${extras.join(', ')})` : '';
|
|
121
|
+
lines.push(`- ${c.name} — ${c.file}${extra}`);
|
|
122
|
+
}
|
|
123
|
+
return lines.join('\n');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function getOrBuildRepoData(root: string): Promise<Cached> {
|
|
127
|
+
const now = Date.now();
|
|
128
|
+
const hit = cache.get(root);
|
|
129
|
+
if (hit && now - hit.ts < CACHE_TTL_MS) return hit;
|
|
130
|
+
|
|
131
|
+
const stack = detectUiStack(root);
|
|
132
|
+
const files = walkFiles(root, {
|
|
133
|
+
includeExtensions: ['.tsx', '.jsx'],
|
|
134
|
+
maxFiles: 5000,
|
|
135
|
+
});
|
|
136
|
+
const catalog = await buildComponentCatalog(root, files);
|
|
137
|
+
|
|
138
|
+
const fresh: Cached = { ts: now, stack, catalog };
|
|
139
|
+
cache.set(root, fresh);
|
|
140
|
+
return fresh;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface RepoGroundingOptions {
|
|
144
|
+
focusFileAbs?: string;
|
|
145
|
+
instruction?: string;
|
|
146
|
+
maxComponents?: number;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Build a concise deterministic “grounding” string.
|
|
151
|
+
*
|
|
152
|
+
* This is safe to include in prompts because it contains only local, deterministic
|
|
153
|
+
* project metadata (no secrets).
|
|
154
|
+
*/
|
|
155
|
+
export async function buildRepoGrounding(config: Config, options: RepoGroundingOptions = {}): Promise<string> {
|
|
156
|
+
const maxComponents = typeof options.maxComponents === 'number' ? options.maxComponents : 120;
|
|
157
|
+
|
|
158
|
+
const root = resolveRootForFile(options.focusFileAbs ? path.resolve(options.focusFileAbs) : null, config);
|
|
159
|
+
const data = await getOrBuildRepoData(root);
|
|
160
|
+
|
|
161
|
+
const focusRel = options.focusFileAbs ? toPosixPath(path.relative(root, path.resolve(options.focusFileAbs))) : null;
|
|
162
|
+
const focusDirRel = focusRel ? toPosixPath(path.posix.dirname(focusRel)) : null;
|
|
163
|
+
const instructionTokens = tokenizeInstruction(options.instruction);
|
|
164
|
+
|
|
165
|
+
// Score and select a small subset for reuse.
|
|
166
|
+
const scored = data.catalog.components
|
|
167
|
+
.map((c) => ({ c, s: scoreComponent(c, focusDirRel, instructionTokens) }))
|
|
168
|
+
.sort((a, b) => b.s - a.s);
|
|
169
|
+
|
|
170
|
+
const subset: ComponentExport[] = [];
|
|
171
|
+
const seen = new Set<string>();
|
|
172
|
+
for (const item of scored) {
|
|
173
|
+
if (subset.length >= maxComponents) break;
|
|
174
|
+
const key = `${item.c.name}|${item.c.file}|${item.c.exportType}`;
|
|
175
|
+
if (seen.has(key)) continue;
|
|
176
|
+
// Skip ultra-low relevance if we already have enough
|
|
177
|
+
if (subset.length > 30 && item.s <= 0) break;
|
|
178
|
+
subset.push(item.c);
|
|
179
|
+
seen.add(key);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return [
|
|
183
|
+
'AUTO PROJECT CONTEXT (deterministic):',
|
|
184
|
+
'',
|
|
185
|
+
'STACK (json):',
|
|
186
|
+
summarizeStack(data.stack),
|
|
187
|
+
'',
|
|
188
|
+
'COMPONENT CATALOG (subset for reuse):',
|
|
189
|
+
formatCatalogSubset(data.catalog, subset),
|
|
190
|
+
].join('\n');
|
|
191
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path Guards
|
|
3
|
+
*
|
|
4
|
+
* Centralized helpers to validate and resolve file paths for tools.
|
|
5
|
+
*
|
|
6
|
+
* Goals:
|
|
7
|
+
* - Prevent accidental reads/writes outside the workspace
|
|
8
|
+
* - Avoid including sensitive files (e.g. .env, private keys)
|
|
9
|
+
* - Provide consistent absolute-path resolution
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as fs from 'node:fs';
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
import { Config } from '../config/index.js';
|
|
15
|
+
import { isPathAllowed, isSensitiveFile } from './filter.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve a path to an absolute path (relative paths are resolved from process.cwd()).
|
|
19
|
+
*/
|
|
20
|
+
export function resolveToAbs(filePath: string): string {
|
|
21
|
+
return path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Validate that a path is safe to read according to server config.
|
|
26
|
+
* Returns the resolved absolute path.
|
|
27
|
+
*/
|
|
28
|
+
export function assertReadablePath(filePath: string, config: Config): string {
|
|
29
|
+
const absPath = resolveToAbs(filePath);
|
|
30
|
+
|
|
31
|
+
if (!isPathAllowed(absPath, config.allowedPaths)) {
|
|
32
|
+
throw new Error(`Path is outside allowedPaths: ${filePath}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (isSensitiveFile(absPath)) {
|
|
36
|
+
throw new Error(`Refusing to read sensitive file: ${filePath}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!fs.existsSync(absPath)) {
|
|
40
|
+
throw new Error(`File not found: ${filePath}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const stat = fs.statSync(absPath);
|
|
44
|
+
if (!stat.isFile()) {
|
|
45
|
+
throw new Error(`Not a file: ${filePath}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return absPath;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Validate that a path is safe to write according to server config.
|
|
53
|
+
* Returns the resolved absolute path.
|
|
54
|
+
*/
|
|
55
|
+
export function assertWritablePath(filePath: string, config: Config): string {
|
|
56
|
+
const absPath = resolveToAbs(filePath);
|
|
57
|
+
|
|
58
|
+
if (!isPathAllowed(absPath, config.allowedPaths)) {
|
|
59
|
+
throw new Error(`Path is outside allowedPaths: ${filePath}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (isSensitiveFile(absPath)) {
|
|
63
|
+
throw new Error(`Refusing to write to sensitive path: ${filePath}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return absPath;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Validate that a directory path is safe to read according to server config.
|
|
71
|
+
* Returns the resolved absolute path.
|
|
72
|
+
*/
|
|
73
|
+
export function assertReadableDir(dirPath: string, config: Config): string {
|
|
74
|
+
const absPath = resolveToAbs(dirPath);
|
|
75
|
+
|
|
76
|
+
if (!isPathAllowed(absPath, config.allowedPaths)) {
|
|
77
|
+
throw new Error(`Path is outside allowedPaths: ${dirPath}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (isSensitiveFile(absPath)) {
|
|
81
|
+
throw new Error(`Refusing to read sensitive directory: ${dirPath}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!fs.existsSync(absPath)) {
|
|
85
|
+
throw new Error(`Directory not found: ${dirPath}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const stat = fs.statSync(absPath);
|
|
89
|
+
if (!stat.isDirectory()) {
|
|
90
|
+
throw new Error(`Not a directory: ${dirPath}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return absPath;
|
|
94
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo Hints
|
|
3
|
+
*
|
|
4
|
+
* Very small, deterministic context that can be injected into LLM prompts
|
|
5
|
+
* to significantly improve output quality while avoiding big token burns.
|
|
6
|
+
*
|
|
7
|
+
* - Detects framework + styling + common libs
|
|
8
|
+
* - Does NOT include file contents
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as path from 'node:path';
|
|
12
|
+
import { Config } from '../config/index.js';
|
|
13
|
+
import { detectUiStack } from '../stack/detect.js';
|
|
14
|
+
|
|
15
|
+
export function buildRepoHints(config: Config, rootDir: string = process.cwd()): string {
|
|
16
|
+
try {
|
|
17
|
+
const stack = detectUiStack(rootDir);
|
|
18
|
+
|
|
19
|
+
const hints = {
|
|
20
|
+
root: path.basename(stack.root),
|
|
21
|
+
framework: stack.framework,
|
|
22
|
+
typescript: stack.language.typescript,
|
|
23
|
+
styling: stack.styling,
|
|
24
|
+
uiLibraries: stack.uiLibraries,
|
|
25
|
+
iconLibraries: stack.iconLibraries,
|
|
26
|
+
tooling: stack.tooling,
|
|
27
|
+
conventions: {
|
|
28
|
+
srcDir: stack.conventions.srcDir,
|
|
29
|
+
hasAppDir: stack.conventions.hasAppDir,
|
|
30
|
+
hasPagesDir: stack.conventions.hasPagesDir,
|
|
31
|
+
tsconfigPaths: stack.conventions.tsconfigPaths,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
return `REPO_HINTS (deterministic):\n${JSON.stringify(hints, null, 2)}`;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (config.debug) {
|
|
38
|
+
const msg = error instanceof Error ? error.message : 'unknown error';
|
|
39
|
+
console.error('[repo-hints] Failed to detect stack:', msg);
|
|
40
|
+
}
|
|
41
|
+
return '';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini API Client
|
|
3
|
+
*
|
|
4
|
+
* Handles direct communication with Google Gemini API.
|
|
5
|
+
* Used in local mode with user's own API key.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { GoogleGenerativeAI } from '@google/generative-ai';
|
|
9
|
+
import { Config } from '../config/index.js';
|
|
10
|
+
|
|
11
|
+
export type GeminiUserContent =
|
|
12
|
+
| string
|
|
13
|
+
| Array<
|
|
14
|
+
| { text: string }
|
|
15
|
+
| { inlineData: { mimeType: string; data: string } }
|
|
16
|
+
>;
|
|
17
|
+
|
|
18
|
+
// Cache the client instance
|
|
19
|
+
let genAI: GoogleGenerativeAI | null = null;
|
|
20
|
+
|
|
21
|
+
export interface GenerateOptions {
|
|
22
|
+
toolName?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get or create the Gemini client
|
|
27
|
+
*/
|
|
28
|
+
function getClient(config: Config): GoogleGenerativeAI {
|
|
29
|
+
if (!config.apiKey) {
|
|
30
|
+
throw new Error('Gemini API key not configured. Set GEMINI_API_KEY environment variable.');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!genAI) {
|
|
34
|
+
genAI = new GoogleGenerativeAI(config.apiKey);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return genAI;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Generate content using Gemini
|
|
42
|
+
*/
|
|
43
|
+
export async function generateWithGemini(
|
|
44
|
+
config: Config,
|
|
45
|
+
systemPrompt: string,
|
|
46
|
+
userPrompt: GeminiUserContent,
|
|
47
|
+
options?: GenerateOptions
|
|
48
|
+
): Promise<string> {
|
|
49
|
+
// If remote mode, delegate to remote client
|
|
50
|
+
if (config.mode === 'remote') {
|
|
51
|
+
const { generateWithRemote } = await import('./remote-client.js');
|
|
52
|
+
return generateWithRemote(config, systemPrompt, userPrompt, options?.toolName);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// If local mode is configured to use an OpenAI-compatible proxy (e.g. LiteLLM)
|
|
56
|
+
if (config.localProvider === 'litellm') {
|
|
57
|
+
const { generateWithLiteLLM } = await import('./litellm-client.js');
|
|
58
|
+
return generateWithLiteLLM(config, systemPrompt, userPrompt, options?.toolName);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const client = getClient(config);
|
|
62
|
+
|
|
63
|
+
// Use model from config (default: gemini-2.5-flash-lite)
|
|
64
|
+
const model = client.getGenerativeModel({
|
|
65
|
+
model: config.model,
|
|
66
|
+
systemInstruction: systemPrompt,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const result = await model.generateContent(userPrompt as any);
|
|
70
|
+
const response = result.response;
|
|
71
|
+
const text = response.text();
|
|
72
|
+
|
|
73
|
+
if (config.debug) {
|
|
74
|
+
console.error('[gemini] Tool:', options?.toolName || '(unknown)');
|
|
75
|
+
console.error('[gemini] Response length:', text.length);
|
|
76
|
+
console.error('[gemini] Usage:', response.usageMetadata);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return text;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Count tokens for a given text (useful for quota tracking)
|
|
84
|
+
*/
|
|
85
|
+
export async function countTokens(config: Config, text: string): Promise<number> {
|
|
86
|
+
if (config.localProvider === 'litellm') {
|
|
87
|
+
throw new Error('countTokens is not supported when localProvider=litellm');
|
|
88
|
+
}
|
|
89
|
+
const client = getClient(config);
|
|
90
|
+
const model = client.getGenerativeModel({ model: config.model });
|
|
91
|
+
|
|
92
|
+
const result = await model.countTokens(text);
|
|
93
|
+
return result.totalTokens;
|
|
94
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LiteLLM (OpenAI-compatible) Client
|
|
3
|
+
*
|
|
4
|
+
* This client allows running the MCP server in local mode while routing
|
|
5
|
+
* requests through a self-hosted LiteLLM instance.
|
|
6
|
+
*
|
|
7
|
+
* Why LiteLLM?
|
|
8
|
+
* - Key management / key rotation
|
|
9
|
+
* - Load balancing across multiple upstream API keys
|
|
10
|
+
* - Unified OpenAI-compatible interface
|
|
11
|
+
* - Optional caching / retries / budgets
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Config } from '../config/index.js';
|
|
15
|
+
import type { GeminiUserContent } from './gemini-client.js';
|
|
16
|
+
|
|
17
|
+
type ChatContentPart =
|
|
18
|
+
| { type: 'text'; text: string }
|
|
19
|
+
| { type: 'image_url'; image_url: { url: string } };
|
|
20
|
+
|
|
21
|
+
function normalizeBaseUrl(baseUrl: string): string {
|
|
22
|
+
const trimmed = baseUrl.replace(/\/+$/, '');
|
|
23
|
+
// Allow users to provide either http://host:port or http://host:port/v1
|
|
24
|
+
if (trimmed.endsWith('/v1')) return trimmed;
|
|
25
|
+
return `${trimmed}/v1`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function inferMimeTypeFromInlineData(mimeType: string): string {
|
|
29
|
+
// LiteLLM expects a data URL. Ensure mime type is reasonable.
|
|
30
|
+
if (!mimeType || !mimeType.includes('/')) return 'application/octet-stream';
|
|
31
|
+
return mimeType;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function toOpenAIUserContent(userContent: GeminiUserContent): string | ChatContentPart[] {
|
|
35
|
+
if (typeof userContent === 'string') return userContent;
|
|
36
|
+
|
|
37
|
+
const parts: ChatContentPart[] = [];
|
|
38
|
+
for (const part of userContent) {
|
|
39
|
+
if ('text' in part) {
|
|
40
|
+
parts.push({ type: 'text', text: part.text });
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if ('inlineData' in part) {
|
|
45
|
+
const mime = inferMimeTypeFromInlineData(part.inlineData.mimeType);
|
|
46
|
+
const url = `data:${mime};base64,${part.inlineData.data}`;
|
|
47
|
+
parts.push({ type: 'image_url', image_url: { url } });
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// If the model only received one text part, prefer string (cheaper payload)
|
|
53
|
+
if (parts.length === 1 && parts[0].type === 'text') return parts[0].text;
|
|
54
|
+
return parts;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function generateWithLiteLLM(
|
|
58
|
+
config: Config,
|
|
59
|
+
systemPrompt: string,
|
|
60
|
+
userContent: GeminiUserContent,
|
|
61
|
+
toolName?: string
|
|
62
|
+
): Promise<string> {
|
|
63
|
+
if (!config.litellmEndpoint) {
|
|
64
|
+
throw new Error('LiteLLM endpoint not configured. Set LITELLM_ENDPOINT.');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const baseUrl = normalizeBaseUrl(config.litellmEndpoint);
|
|
68
|
+
const url = `${baseUrl}/chat/completions`;
|
|
69
|
+
|
|
70
|
+
const model = config.litellmModel || config.model;
|
|
71
|
+
|
|
72
|
+
const body = {
|
|
73
|
+
model,
|
|
74
|
+
messages: [
|
|
75
|
+
{ role: 'system', content: systemPrompt },
|
|
76
|
+
{ role: 'user', content: toOpenAIUserContent(userContent) },
|
|
77
|
+
],
|
|
78
|
+
// Keep default-ish sampling. Let downstream agents control via prompts.
|
|
79
|
+
temperature: 0.2,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const headers: Record<string, string> = {
|
|
83
|
+
'Content-Type': 'application/json',
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
if (config.litellmApiKey) {
|
|
87
|
+
headers.Authorization = `Bearer ${config.litellmApiKey}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const resp = await fetch(url, {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers,
|
|
93
|
+
body: JSON.stringify(body),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
if (!resp.ok) {
|
|
97
|
+
const text = await resp.text();
|
|
98
|
+
throw new Error(`LiteLLM error (${resp.status}): ${text}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const data = (await resp.json()) as any;
|
|
102
|
+
const content: unknown = data?.choices?.[0]?.message?.content;
|
|
103
|
+
|
|
104
|
+
if (config.debug) {
|
|
105
|
+
const usage = data?.usage;
|
|
106
|
+
console.error('[litellm] Tool:', toolName || '(unknown)');
|
|
107
|
+
if (usage) console.error('[litellm] Usage:', usage);
|
|
108
|
+
console.error('[litellm] Model:', data?.model || model);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (typeof content === 'string') return content;
|
|
112
|
+
// Some providers return arrays; stringify safely.
|
|
113
|
+
if (Array.isArray(content)) {
|
|
114
|
+
return content
|
|
115
|
+
.map((p) => (typeof p === 'string' ? p : (p?.text ?? '')))
|
|
116
|
+
.filter(Boolean)
|
|
117
|
+
.join('');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return '';
|
|
121
|
+
}
|