@codebakers/cli 2.9.0 → 3.0.1
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/commands/billing.d.ts +4 -0
- package/dist/commands/billing.js +91 -0
- package/dist/commands/extend.d.ts +4 -0
- package/dist/commands/extend.js +141 -0
- package/dist/commands/go.d.ts +4 -0
- package/dist/commands/go.js +328 -0
- package/dist/config.d.ts +33 -0
- package/dist/config.js +83 -1
- package/dist/index.js +23 -5
- package/dist/lib/fingerprint.d.ts +23 -0
- package/dist/lib/fingerprint.js +136 -0
- package/dist/mcp/server.js +1319 -16
- package/package.json +1 -1
- package/src/commands/billing.ts +99 -0
- package/src/commands/extend.ts +157 -0
- package/src/commands/go.ts +386 -0
- package/src/config.ts +101 -1
- package/src/index.ts +26 -5
- package/src/lib/fingerprint.ts +122 -0
- package/src/mcp/server.ts +1524 -17
package/src/mcp/server.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
ErrorCode,
|
|
9
9
|
McpError,
|
|
10
10
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
11
|
-
import { getApiKey, getApiUrl, getExperienceLevel, setExperienceLevel, getServiceKey, setServiceKey, type ExperienceLevel } from '../config.js';
|
|
11
|
+
import { getApiKey, getApiUrl, getExperienceLevel, setExperienceLevel, getServiceKey, setServiceKey, getTrialState, isTrialExpired, getTrialDaysRemaining, hasValidAccess, getAuthMode, type ExperienceLevel, type TrialState } from '../config.js';
|
|
12
12
|
import { audit as runAudit } from '../commands/audit.js';
|
|
13
13
|
import { heal as runHeal } from '../commands/heal.js';
|
|
14
14
|
import { getCliVersion } from '../lib/api.js';
|
|
@@ -61,12 +61,16 @@ class CodeBakersServer {
|
|
|
61
61
|
private server: Server;
|
|
62
62
|
private apiKey: string | null;
|
|
63
63
|
private apiUrl: string;
|
|
64
|
+
private trialState: TrialState | null;
|
|
65
|
+
private authMode: 'apiKey' | 'trial' | 'none';
|
|
64
66
|
private autoUpdateChecked = false;
|
|
65
67
|
private autoUpdateInProgress = false;
|
|
66
68
|
|
|
67
69
|
constructor() {
|
|
68
70
|
this.apiKey = getApiKey();
|
|
69
71
|
this.apiUrl = getApiUrl();
|
|
72
|
+
this.trialState = getTrialState();
|
|
73
|
+
this.authMode = getAuthMode();
|
|
70
74
|
|
|
71
75
|
this.server = new Server(
|
|
72
76
|
{
|
|
@@ -88,12 +92,28 @@ class CodeBakersServer {
|
|
|
88
92
|
});
|
|
89
93
|
}
|
|
90
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Get authorization headers for API requests
|
|
97
|
+
* Supports both API key (paid users) and trial ID (free users)
|
|
98
|
+
*/
|
|
99
|
+
private getAuthHeaders(): Record<string, string> {
|
|
100
|
+
if (this.apiKey) {
|
|
101
|
+
return { 'Authorization': `Bearer ${this.apiKey}` };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (this.trialState?.trialId) {
|
|
105
|
+
return { 'X-Trial-Id': this.trialState.trialId };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {};
|
|
109
|
+
}
|
|
110
|
+
|
|
91
111
|
/**
|
|
92
112
|
* Automatically check for and apply pattern updates
|
|
93
113
|
* Runs silently in background - no user intervention needed
|
|
94
114
|
*/
|
|
95
115
|
private async checkAndAutoUpdate(): Promise<void> {
|
|
96
|
-
if (this.autoUpdateChecked || this.autoUpdateInProgress ||
|
|
116
|
+
if (this.autoUpdateChecked || this.autoUpdateInProgress || this.authMode === 'none') {
|
|
97
117
|
return;
|
|
98
118
|
}
|
|
99
119
|
|
|
@@ -131,7 +151,7 @@ class CodeBakersServer {
|
|
|
131
151
|
|
|
132
152
|
// Fetch latest version
|
|
133
153
|
const response = await fetch(`${this.apiUrl}/api/content/version`, {
|
|
134
|
-
headers:
|
|
154
|
+
headers: this.getAuthHeaders(),
|
|
135
155
|
});
|
|
136
156
|
|
|
137
157
|
if (!response.ok) {
|
|
@@ -153,7 +173,7 @@ class CodeBakersServer {
|
|
|
153
173
|
|
|
154
174
|
// Fetch full content and update
|
|
155
175
|
const contentResponse = await fetch(`${this.apiUrl}/api/content`, {
|
|
156
|
-
headers:
|
|
176
|
+
headers: this.getAuthHeaders(),
|
|
157
177
|
});
|
|
158
178
|
|
|
159
179
|
if (!contentResponse.ok) {
|
|
@@ -400,7 +420,7 @@ class CodeBakersServer {
|
|
|
400
420
|
let latest: { version: string; moduleCount: number } | null = null;
|
|
401
421
|
try {
|
|
402
422
|
const response = await fetch(`${this.apiUrl}/api/content/version`, {
|
|
403
|
-
headers: this.
|
|
423
|
+
headers: this.getAuthHeaders(),
|
|
404
424
|
});
|
|
405
425
|
if (response.ok) {
|
|
406
426
|
latest = await response.json();
|
|
@@ -874,18 +894,133 @@ class CodeBakersServer {
|
|
|
874
894
|
required: ['token'],
|
|
875
895
|
},
|
|
876
896
|
},
|
|
897
|
+
{
|
|
898
|
+
name: 'update_constant',
|
|
899
|
+
description:
|
|
900
|
+
'Update a business constant (pricing, trial days, module count, etc.) using natural language. Use this when user says things like "change Pro price to $59", "set trial to 10 days", "update module count to 45". Automatically edits src/lib/constants.ts.',
|
|
901
|
+
inputSchema: {
|
|
902
|
+
type: 'object' as const,
|
|
903
|
+
properties: {
|
|
904
|
+
request: {
|
|
905
|
+
type: 'string',
|
|
906
|
+
description: 'Natural language request describing what to change (e.g., "change Pro monthly price to $59", "set anonymous trial days to 10", "update Agency seats to 10")',
|
|
907
|
+
},
|
|
908
|
+
},
|
|
909
|
+
required: ['request'],
|
|
910
|
+
},
|
|
911
|
+
},
|
|
912
|
+
{
|
|
913
|
+
name: 'update_schema',
|
|
914
|
+
description:
|
|
915
|
+
'Add or modify database tables using natural language. Use this when user says things like "add a tags table", "add a status field to users", "create a comments table with user_id and content". Automatically edits src/db/schema.ts and creates migration.',
|
|
916
|
+
inputSchema: {
|
|
917
|
+
type: 'object' as const,
|
|
918
|
+
properties: {
|
|
919
|
+
request: {
|
|
920
|
+
type: 'string',
|
|
921
|
+
description: 'Natural language request describing schema changes (e.g., "add a tags table with name and color fields", "add isArchived boolean to projects table", "create a comments table")',
|
|
922
|
+
},
|
|
923
|
+
},
|
|
924
|
+
required: ['request'],
|
|
925
|
+
},
|
|
926
|
+
},
|
|
927
|
+
{
|
|
928
|
+
name: 'update_env',
|
|
929
|
+
description:
|
|
930
|
+
'Add or update environment variables. Use this when user says things like "add OPENAI_API_KEY", "set up Stripe keys", "add database URL". Updates both .env.local and .env.example.',
|
|
931
|
+
inputSchema: {
|
|
932
|
+
type: 'object' as const,
|
|
933
|
+
properties: {
|
|
934
|
+
request: {
|
|
935
|
+
type: 'string',
|
|
936
|
+
description: 'Natural language request describing env vars to add (e.g., "add OPENAI_API_KEY", "add Stripe test keys", "add RESEND_API_KEY for emails")',
|
|
937
|
+
},
|
|
938
|
+
},
|
|
939
|
+
required: ['request'],
|
|
940
|
+
},
|
|
941
|
+
},
|
|
942
|
+
{
|
|
943
|
+
name: 'billing_action',
|
|
944
|
+
description:
|
|
945
|
+
'Perform billing and subscription actions. Use this when user says things like "show my subscription", "extend my trial", "upgrade to Pro", "check my usage". Opens billing page or shows subscription info.',
|
|
946
|
+
inputSchema: {
|
|
947
|
+
type: 'object' as const,
|
|
948
|
+
properties: {
|
|
949
|
+
action: {
|
|
950
|
+
type: 'string',
|
|
951
|
+
description: 'Natural language billing action (e.g., "show my subscription", "extend trial", "upgrade to team", "check usage")',
|
|
952
|
+
},
|
|
953
|
+
},
|
|
954
|
+
required: ['action'],
|
|
955
|
+
},
|
|
956
|
+
},
|
|
957
|
+
{
|
|
958
|
+
name: 'add_page',
|
|
959
|
+
description:
|
|
960
|
+
'Create a new page or route in the Next.js app. Use this when user says things like "create a settings page", "add an about page", "make a dashboard page with stats". Creates the file in src/app/ with proper structure.',
|
|
961
|
+
inputSchema: {
|
|
962
|
+
type: 'object' as const,
|
|
963
|
+
properties: {
|
|
964
|
+
request: {
|
|
965
|
+
type: 'string',
|
|
966
|
+
description: 'Natural language request describing the page (e.g., "create a settings page with tabs", "add an about page", "make a user profile page")',
|
|
967
|
+
},
|
|
968
|
+
},
|
|
969
|
+
required: ['request'],
|
|
970
|
+
},
|
|
971
|
+
},
|
|
972
|
+
{
|
|
973
|
+
name: 'add_api_route',
|
|
974
|
+
description:
|
|
975
|
+
'Create a new API route endpoint. Use this when user says things like "create a feedback endpoint", "add an API for user preferences", "make a webhook endpoint for Stripe". Creates properly structured route.ts file.',
|
|
976
|
+
inputSchema: {
|
|
977
|
+
type: 'object' as const,
|
|
978
|
+
properties: {
|
|
979
|
+
request: {
|
|
980
|
+
type: 'string',
|
|
981
|
+
description: 'Natural language request describing the API route (e.g., "create POST endpoint for feedback", "add GET/POST for user settings", "make a Stripe webhook endpoint")',
|
|
982
|
+
},
|
|
983
|
+
},
|
|
984
|
+
required: ['request'],
|
|
985
|
+
},
|
|
986
|
+
},
|
|
877
987
|
],
|
|
878
988
|
}));
|
|
879
989
|
|
|
880
990
|
// Handle tool calls
|
|
881
991
|
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
882
|
-
|
|
992
|
+
// Check access: API key OR valid trial
|
|
993
|
+
if (this.authMode === 'none') {
|
|
883
994
|
throw new McpError(
|
|
884
995
|
ErrorCode.InvalidRequest,
|
|
885
|
-
'Not logged in. Run `codebakers
|
|
996
|
+
'Not logged in. Run `codebakers go` to start a free trial, or `codebakers setup` if you have an account.'
|
|
886
997
|
);
|
|
887
998
|
}
|
|
888
999
|
|
|
1000
|
+
// Check if trial expired
|
|
1001
|
+
if (this.authMode === 'trial' && isTrialExpired()) {
|
|
1002
|
+
const trialState = getTrialState();
|
|
1003
|
+
if (trialState?.stage === 'anonymous') {
|
|
1004
|
+
throw new McpError(
|
|
1005
|
+
ErrorCode.InvalidRequest,
|
|
1006
|
+
'Trial expired. Run `codebakers extend` to add 7 more days with GitHub, or `codebakers billing` to upgrade.'
|
|
1007
|
+
);
|
|
1008
|
+
} else {
|
|
1009
|
+
throw new McpError(
|
|
1010
|
+
ErrorCode.InvalidRequest,
|
|
1011
|
+
'Trial expired. Run `codebakers billing` to upgrade to a paid plan.'
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// Show warning if trial expiring soon
|
|
1017
|
+
if (this.authMode === 'trial') {
|
|
1018
|
+
const daysRemaining = getTrialDaysRemaining();
|
|
1019
|
+
if (daysRemaining <= 2) {
|
|
1020
|
+
console.error(`[CodeBakers] Trial expires in ${daysRemaining} day${daysRemaining !== 1 ? 's' : ''}. Run 'codebakers extend' or 'codebakers billing'.`);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
889
1024
|
const { name, arguments: args } = request.params;
|
|
890
1025
|
|
|
891
1026
|
switch (name) {
|
|
@@ -958,6 +1093,24 @@ class CodeBakersServer {
|
|
|
958
1093
|
case 'vercel_connect':
|
|
959
1094
|
return this.handleVercelConnect(args as { token: string });
|
|
960
1095
|
|
|
1096
|
+
case 'update_constant':
|
|
1097
|
+
return this.handleUpdateConstant(args as { request: string });
|
|
1098
|
+
|
|
1099
|
+
case 'update_schema':
|
|
1100
|
+
return this.handleUpdateSchema(args as { request: string });
|
|
1101
|
+
|
|
1102
|
+
case 'update_env':
|
|
1103
|
+
return this.handleUpdateEnv(args as { request: string });
|
|
1104
|
+
|
|
1105
|
+
case 'billing_action':
|
|
1106
|
+
return this.handleBillingAction(args as { action: string });
|
|
1107
|
+
|
|
1108
|
+
case 'add_page':
|
|
1109
|
+
return this.handleAddPage(args as { request: string });
|
|
1110
|
+
|
|
1111
|
+
case 'add_api_route':
|
|
1112
|
+
return this.handleAddApiRoute(args as { request: string });
|
|
1113
|
+
|
|
961
1114
|
default:
|
|
962
1115
|
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
|
|
963
1116
|
}
|
|
@@ -977,7 +1130,7 @@ class CodeBakersServer {
|
|
|
977
1130
|
method: 'POST',
|
|
978
1131
|
headers: {
|
|
979
1132
|
'Content-Type': 'application/json',
|
|
980
|
-
|
|
1133
|
+
...this.getAuthHeaders(),
|
|
981
1134
|
},
|
|
982
1135
|
body: JSON.stringify({
|
|
983
1136
|
prompt: userRequest,
|
|
@@ -1103,9 +1256,7 @@ Show the user what their simple request was expanded into, then proceed with the
|
|
|
1103
1256
|
private async handleListPatterns() {
|
|
1104
1257
|
const response = await fetch(`${this.apiUrl}/api/patterns`, {
|
|
1105
1258
|
method: 'GET',
|
|
1106
|
-
headers:
|
|
1107
|
-
Authorization: `Bearer ${this.apiKey}`,
|
|
1108
|
-
},
|
|
1259
|
+
headers: this.getAuthHeaders(),
|
|
1109
1260
|
});
|
|
1110
1261
|
|
|
1111
1262
|
if (!response.ok) {
|
|
@@ -1164,7 +1315,7 @@ Show the user what their simple request was expanded into, then proceed with the
|
|
|
1164
1315
|
method: 'POST',
|
|
1165
1316
|
headers: {
|
|
1166
1317
|
'Content-Type': 'application/json',
|
|
1167
|
-
|
|
1318
|
+
...this.getAuthHeaders(),
|
|
1168
1319
|
},
|
|
1169
1320
|
body: JSON.stringify({ patterns }),
|
|
1170
1321
|
});
|
|
@@ -1188,7 +1339,7 @@ Show the user what their simple request was expanded into, then proceed with the
|
|
|
1188
1339
|
method: 'POST',
|
|
1189
1340
|
headers: {
|
|
1190
1341
|
'Content-Type': 'application/json',
|
|
1191
|
-
|
|
1342
|
+
...this.getAuthHeaders(),
|
|
1192
1343
|
},
|
|
1193
1344
|
body: JSON.stringify({ query }),
|
|
1194
1345
|
});
|
|
@@ -1475,7 +1626,7 @@ Show the user what their simple request was expanded into, then proceed with the
|
|
|
1475
1626
|
|
|
1476
1627
|
const response = await fetch(`${this.apiUrl}/api/content`, {
|
|
1477
1628
|
method: 'GET',
|
|
1478
|
-
headers:
|
|
1629
|
+
headers: this.getAuthHeaders(),
|
|
1479
1630
|
});
|
|
1480
1631
|
|
|
1481
1632
|
if (response.ok) {
|
|
@@ -1881,7 +2032,7 @@ Or if user declines, call without fullDeploy:
|
|
|
1881
2032
|
try {
|
|
1882
2033
|
const response = await fetch(`${this.apiUrl}/api/content`, {
|
|
1883
2034
|
method: 'GET',
|
|
1884
|
-
headers:
|
|
2035
|
+
headers: this.getAuthHeaders(),
|
|
1885
2036
|
});
|
|
1886
2037
|
|
|
1887
2038
|
if (!response.ok) {
|
|
@@ -2991,7 +3142,7 @@ Just describe what you want to build! I'll automatically:
|
|
|
2991
3142
|
method: 'POST',
|
|
2992
3143
|
headers: {
|
|
2993
3144
|
'Content-Type': 'application/json',
|
|
2994
|
-
|
|
3145
|
+
...this.getAuthHeaders(),
|
|
2995
3146
|
},
|
|
2996
3147
|
body: JSON.stringify({
|
|
2997
3148
|
category,
|
|
@@ -3043,7 +3194,7 @@ Just describe what you want to build! I'll automatically:
|
|
|
3043
3194
|
method: 'POST',
|
|
3044
3195
|
headers: {
|
|
3045
3196
|
'Content-Type': 'application/json',
|
|
3046
|
-
|
|
3197
|
+
...this.getAuthHeaders(),
|
|
3047
3198
|
},
|
|
3048
3199
|
body: JSON.stringify({
|
|
3049
3200
|
eventType,
|
|
@@ -3534,6 +3685,1362 @@ Just describe what you want to build! I'll automatically:
|
|
|
3534
3685
|
}
|
|
3535
3686
|
}
|
|
3536
3687
|
|
|
3688
|
+
/**
|
|
3689
|
+
* Handle update_constant tool - updates constants via natural language
|
|
3690
|
+
* Parses requests like "change Pro price to $59" and edits constants.ts
|
|
3691
|
+
*/
|
|
3692
|
+
private async handleUpdateConstant(args: { request: string }) {
|
|
3693
|
+
const { request } = args;
|
|
3694
|
+
const cwd = process.cwd();
|
|
3695
|
+
|
|
3696
|
+
// Find constants.ts file (server project)
|
|
3697
|
+
const possiblePaths = [
|
|
3698
|
+
path.join(cwd, 'src', 'lib', 'constants.ts'),
|
|
3699
|
+
path.join(cwd, 'lib', 'constants.ts'),
|
|
3700
|
+
path.join(cwd, 'constants.ts'),
|
|
3701
|
+
];
|
|
3702
|
+
|
|
3703
|
+
let constantsPath: string | null = null;
|
|
3704
|
+
for (const p of possiblePaths) {
|
|
3705
|
+
if (fs.existsSync(p)) {
|
|
3706
|
+
constantsPath = p;
|
|
3707
|
+
break;
|
|
3708
|
+
}
|
|
3709
|
+
}
|
|
3710
|
+
|
|
3711
|
+
if (!constantsPath) {
|
|
3712
|
+
return {
|
|
3713
|
+
content: [{
|
|
3714
|
+
type: 'text' as const,
|
|
3715
|
+
text: `❌ Could not find constants.ts file.\n\nLooked in:\n${possiblePaths.map(p => `- ${p}`).join('\n')}\n\nMake sure you're in the right directory or create src/lib/constants.ts first.`,
|
|
3716
|
+
}],
|
|
3717
|
+
isError: true,
|
|
3718
|
+
};
|
|
3719
|
+
}
|
|
3720
|
+
|
|
3721
|
+
// Read current constants file
|
|
3722
|
+
const currentContent = fs.readFileSync(constantsPath, 'utf-8');
|
|
3723
|
+
|
|
3724
|
+
// Parse the natural language request to identify what to change
|
|
3725
|
+
const parsed = this.parseConstantRequest(request);
|
|
3726
|
+
|
|
3727
|
+
if (!parsed.success) {
|
|
3728
|
+
return {
|
|
3729
|
+
content: [{
|
|
3730
|
+
type: 'text' as const,
|
|
3731
|
+
text: `❓ I couldn't understand that request.\n\n**Request:** "${request}"\n\n**Supported changes:**\n- "change Pro monthly price to $59"\n- "set Team yearly price to $1200"\n- "update Agency seats to 10"\n- "set anonymous trial days to 10"\n- "change extended trial days to 14"\n- "update module count to 45"\n- "change support email to help@example.com"\n- "update tagline to 'Build faster'"\n\n**Try rephrasing** your request with the constant name and new value.`,
|
|
3732
|
+
}],
|
|
3733
|
+
};
|
|
3734
|
+
}
|
|
3735
|
+
|
|
3736
|
+
// Apply the change
|
|
3737
|
+
const { constantPath, newValue, description } = parsed;
|
|
3738
|
+
const updatedContent = this.updateConstantValue(currentContent, constantPath!, newValue!);
|
|
3739
|
+
|
|
3740
|
+
if (updatedContent === currentContent) {
|
|
3741
|
+
return {
|
|
3742
|
+
content: [{
|
|
3743
|
+
type: 'text' as const,
|
|
3744
|
+
text: `⚠️ No changes made.\n\nCouldn't find or update: \`${constantPath}\`\n\nThe constant might not exist or the path format is different.`,
|
|
3745
|
+
}],
|
|
3746
|
+
};
|
|
3747
|
+
}
|
|
3748
|
+
|
|
3749
|
+
// Write the updated file
|
|
3750
|
+
fs.writeFileSync(constantsPath, updatedContent, 'utf-8');
|
|
3751
|
+
|
|
3752
|
+
return {
|
|
3753
|
+
content: [{
|
|
3754
|
+
type: 'text' as const,
|
|
3755
|
+
text: `✅ **Constant Updated**\n\n**Change:** ${description}\n**File:** \`${path.relative(cwd, constantsPath)}\`\n\n---\n\n⚡ This change is now active across the entire codebase.\n\nAll components, pages, and APIs that reference this constant will automatically use the new value.`,
|
|
3756
|
+
}],
|
|
3757
|
+
};
|
|
3758
|
+
}
|
|
3759
|
+
|
|
3760
|
+
/**
|
|
3761
|
+
* Parse natural language request to identify constant and new value
|
|
3762
|
+
*/
|
|
3763
|
+
private parseConstantRequest(request: string): {
|
|
3764
|
+
success: boolean;
|
|
3765
|
+
constantPath?: string;
|
|
3766
|
+
newValue?: string | number | boolean | null;
|
|
3767
|
+
description?: string;
|
|
3768
|
+
} {
|
|
3769
|
+
const lower = request.toLowerCase();
|
|
3770
|
+
|
|
3771
|
+
// PRICING patterns
|
|
3772
|
+
// "change/set/update pro monthly price to $59" or "pro monthly to 59"
|
|
3773
|
+
const pricingMatch = lower.match(
|
|
3774
|
+
/(?:change|set|update|make)?\s*(pro|team|agency|enterprise)\s*(monthly|yearly)\s*(?:price|cost)?\s*(?:to|=|:)?\s*\$?(\d+)/i
|
|
3775
|
+
);
|
|
3776
|
+
if (pricingMatch) {
|
|
3777
|
+
const plan = pricingMatch[1].toUpperCase();
|
|
3778
|
+
const period = pricingMatch[2].toUpperCase();
|
|
3779
|
+
const value = parseInt(pricingMatch[3]);
|
|
3780
|
+
return {
|
|
3781
|
+
success: true,
|
|
3782
|
+
constantPath: `PRICING.${plan}.${period}`,
|
|
3783
|
+
newValue: value,
|
|
3784
|
+
description: `${plan} ${period.toLowerCase()} price → $${value}`,
|
|
3785
|
+
};
|
|
3786
|
+
}
|
|
3787
|
+
|
|
3788
|
+
// SEATS patterns
|
|
3789
|
+
// "change/set team seats to 10" or "agency seats to unlimited"
|
|
3790
|
+
const seatsMatch = lower.match(
|
|
3791
|
+
/(?:change|set|update|make)?\s*(pro|team|agency|enterprise)\s*seats?\s*(?:to|=|:)?\s*(\d+|unlimited|-1)/i
|
|
3792
|
+
);
|
|
3793
|
+
if (seatsMatch) {
|
|
3794
|
+
const plan = seatsMatch[1].toUpperCase();
|
|
3795
|
+
let value: number;
|
|
3796
|
+
if (seatsMatch[2] === 'unlimited' || seatsMatch[2] === '-1') {
|
|
3797
|
+
value = -1;
|
|
3798
|
+
} else {
|
|
3799
|
+
value = parseInt(seatsMatch[2]);
|
|
3800
|
+
}
|
|
3801
|
+
return {
|
|
3802
|
+
success: true,
|
|
3803
|
+
constantPath: `PRICING.${plan}.SEATS`,
|
|
3804
|
+
newValue: value,
|
|
3805
|
+
description: `${plan} seats → ${value === -1 ? 'unlimited' : value}`,
|
|
3806
|
+
};
|
|
3807
|
+
}
|
|
3808
|
+
|
|
3809
|
+
// TRIAL patterns
|
|
3810
|
+
// "set anonymous trial days to 10" or "anonymous days to 10"
|
|
3811
|
+
const trialMatch = lower.match(
|
|
3812
|
+
/(?:change|set|update|make)?\s*(anonymous|extended|total|expiring)\s*(?:trial)?\s*(?:days|threshold)?\s*(?:to|=|:)?\s*(\d+)/i
|
|
3813
|
+
);
|
|
3814
|
+
if (trialMatch) {
|
|
3815
|
+
const trialType = trialMatch[1].toUpperCase();
|
|
3816
|
+
const value = parseInt(trialMatch[2]);
|
|
3817
|
+
let path: string;
|
|
3818
|
+
let desc: string;
|
|
3819
|
+
|
|
3820
|
+
switch (trialType) {
|
|
3821
|
+
case 'ANONYMOUS':
|
|
3822
|
+
path = 'TRIAL.ANONYMOUS_DAYS';
|
|
3823
|
+
desc = `Anonymous trial days → ${value}`;
|
|
3824
|
+
break;
|
|
3825
|
+
case 'EXTENDED':
|
|
3826
|
+
path = 'TRIAL.EXTENDED_DAYS';
|
|
3827
|
+
desc = `Extended trial days → ${value}`;
|
|
3828
|
+
break;
|
|
3829
|
+
case 'TOTAL':
|
|
3830
|
+
path = 'TRIAL.TOTAL_DAYS';
|
|
3831
|
+
desc = `Total trial days → ${value}`;
|
|
3832
|
+
break;
|
|
3833
|
+
case 'EXPIRING':
|
|
3834
|
+
path = 'TRIAL.EXPIRING_SOON_THRESHOLD';
|
|
3835
|
+
desc = `Expiring warning threshold → ${value} days`;
|
|
3836
|
+
break;
|
|
3837
|
+
default:
|
|
3838
|
+
return { success: false };
|
|
3839
|
+
}
|
|
3840
|
+
return { success: true, constantPath: path, newValue: value, description: desc };
|
|
3841
|
+
}
|
|
3842
|
+
|
|
3843
|
+
// MODULE COUNT pattern
|
|
3844
|
+
// "set module count to 45" or "modules to 45"
|
|
3845
|
+
const moduleMatch = lower.match(
|
|
3846
|
+
/(?:change|set|update|make)?\s*(?:module|pattern)\s*count\s*(?:to|=|:)?\s*(\d+)/i
|
|
3847
|
+
);
|
|
3848
|
+
if (moduleMatch) {
|
|
3849
|
+
const value = parseInt(moduleMatch[1]);
|
|
3850
|
+
return {
|
|
3851
|
+
success: true,
|
|
3852
|
+
constantPath: 'MODULES.COUNT',
|
|
3853
|
+
newValue: value,
|
|
3854
|
+
description: `Module count → ${value}`,
|
|
3855
|
+
};
|
|
3856
|
+
}
|
|
3857
|
+
|
|
3858
|
+
// PRODUCT string patterns
|
|
3859
|
+
// "change tagline to 'Build faster'" or "set support email to help@example.com"
|
|
3860
|
+
const productMatch = lower.match(
|
|
3861
|
+
/(?:change|set|update|make)?\s*(name|tagline|support\s*email|website|cli\s*command)\s*(?:to|=|:)?\s*["']?([^"']+)["']?/i
|
|
3862
|
+
);
|
|
3863
|
+
if (productMatch) {
|
|
3864
|
+
const field = productMatch[1].toLowerCase().replace(/\s+/g, '_').toUpperCase();
|
|
3865
|
+
const value = productMatch[2].trim();
|
|
3866
|
+
let path: string;
|
|
3867
|
+
|
|
3868
|
+
if (field === 'SUPPORT_EMAIL') {
|
|
3869
|
+
path = 'PRODUCT.SUPPORT_EMAIL';
|
|
3870
|
+
} else if (field === 'NAME') {
|
|
3871
|
+
path = 'PRODUCT.NAME';
|
|
3872
|
+
} else if (field === 'TAGLINE') {
|
|
3873
|
+
path = 'PRODUCT.TAGLINE';
|
|
3874
|
+
} else if (field === 'WEBSITE') {
|
|
3875
|
+
path = 'PRODUCT.WEBSITE';
|
|
3876
|
+
} else if (field === 'CLI_COMMAND') {
|
|
3877
|
+
path = 'PRODUCT.CLI_COMMAND';
|
|
3878
|
+
} else {
|
|
3879
|
+
return { success: false };
|
|
3880
|
+
}
|
|
3881
|
+
|
|
3882
|
+
return {
|
|
3883
|
+
success: true,
|
|
3884
|
+
constantPath: path,
|
|
3885
|
+
newValue: value,
|
|
3886
|
+
description: `${field.replace(/_/g, ' ').toLowerCase()} → "${value}"`,
|
|
3887
|
+
};
|
|
3888
|
+
}
|
|
3889
|
+
|
|
3890
|
+
// API KEYS patterns
|
|
3891
|
+
// "set rate limit to 100 per minute"
|
|
3892
|
+
const rateLimitMatch = lower.match(
|
|
3893
|
+
/(?:change|set|update|make)?\s*(?:rate\s*limit|requests)\s*(?:per|\/)\s*(minute|hour)\s*(?:to|=|:)?\s*(\d+)/i
|
|
3894
|
+
);
|
|
3895
|
+
if (rateLimitMatch) {
|
|
3896
|
+
const period = rateLimitMatch[1].toUpperCase();
|
|
3897
|
+
const value = parseInt(rateLimitMatch[2]);
|
|
3898
|
+
const path = period === 'MINUTE'
|
|
3899
|
+
? 'API_KEYS.RATE_LIMIT.REQUESTS_PER_MINUTE'
|
|
3900
|
+
: 'API_KEYS.RATE_LIMIT.REQUESTS_PER_HOUR';
|
|
3901
|
+
return {
|
|
3902
|
+
success: true,
|
|
3903
|
+
constantPath: path,
|
|
3904
|
+
newValue: value,
|
|
3905
|
+
description: `Rate limit → ${value} requests per ${period.toLowerCase()}`,
|
|
3906
|
+
};
|
|
3907
|
+
}
|
|
3908
|
+
|
|
3909
|
+
// FEATURE FLAGS patterns
|
|
3910
|
+
// "enable/disable trial system" or "set trial system to true/false"
|
|
3911
|
+
const featureMatch = lower.match(
|
|
3912
|
+
/(?:enable|disable|turn\s*on|turn\s*off|set)?\s*(trial\s*system|github\s*extension|anonymous\s*trial)\s*(?:enabled|disabled|on|off|to\s*true|to\s*false)?/i
|
|
3913
|
+
);
|
|
3914
|
+
if (featureMatch) {
|
|
3915
|
+
const feature = featureMatch[1].toLowerCase().replace(/\s+/g, '_').toUpperCase();
|
|
3916
|
+
const enabled = lower.includes('enable') || lower.includes('turn on') || lower.includes('to true');
|
|
3917
|
+
let path: string;
|
|
3918
|
+
|
|
3919
|
+
if (feature.includes('TRIAL_SYSTEM')) {
|
|
3920
|
+
path = 'FEATURES.TRIAL_SYSTEM_ENABLED';
|
|
3921
|
+
} else if (feature.includes('GITHUB_EXTENSION')) {
|
|
3922
|
+
path = 'FEATURES.GITHUB_EXTENSION_ENABLED';
|
|
3923
|
+
} else if (feature.includes('ANONYMOUS_TRIAL')) {
|
|
3924
|
+
path = 'FEATURES.ANONYMOUS_TRIAL_ENABLED';
|
|
3925
|
+
} else {
|
|
3926
|
+
return { success: false };
|
|
3927
|
+
}
|
|
3928
|
+
|
|
3929
|
+
return {
|
|
3930
|
+
success: true,
|
|
3931
|
+
constantPath: path,
|
|
3932
|
+
newValue: enabled,
|
|
3933
|
+
description: `${feature.replace(/_/g, ' ').toLowerCase()} → ${enabled ? 'enabled' : 'disabled'}`,
|
|
3934
|
+
};
|
|
3935
|
+
}
|
|
3936
|
+
|
|
3937
|
+
return { success: false };
|
|
3938
|
+
}
|
|
3939
|
+
|
|
3940
|
+
/**
|
|
3941
|
+
* Update a specific constant value in the file content
|
|
3942
|
+
*/
|
|
3943
|
+
private updateConstantValue(
|
|
3944
|
+
content: string,
|
|
3945
|
+
constantPath: string,
|
|
3946
|
+
newValue: string | number | boolean | null
|
|
3947
|
+
): string {
|
|
3948
|
+
const parts = constantPath.split('.');
|
|
3949
|
+
|
|
3950
|
+
// Format the new value for TypeScript
|
|
3951
|
+
let formattedValue: string;
|
|
3952
|
+
if (typeof newValue === 'string') {
|
|
3953
|
+
formattedValue = `'${newValue}'`;
|
|
3954
|
+
} else if (typeof newValue === 'boolean') {
|
|
3955
|
+
formattedValue = newValue.toString();
|
|
3956
|
+
} else if (newValue === null) {
|
|
3957
|
+
formattedValue = 'null';
|
|
3958
|
+
} else {
|
|
3959
|
+
formattedValue = newValue.toString();
|
|
3960
|
+
}
|
|
3961
|
+
|
|
3962
|
+
// Build regex to find and replace the value
|
|
3963
|
+
// e.g., for PRICING.PRO.MONTHLY, look for "MONTHLY: <number>" within PRO block
|
|
3964
|
+
if (parts.length === 2) {
|
|
3965
|
+
// Simple case: MODULES.COUNT or FEATURES.X
|
|
3966
|
+
const [obj, key] = parts;
|
|
3967
|
+
const regex = new RegExp(`(${key}:\\s*)([^,}\\n]+)`, 'g');
|
|
3968
|
+
|
|
3969
|
+
// Find the right object and update within it
|
|
3970
|
+
const objRegex = new RegExp(`(export const ${obj}\\s*=\\s*\\{[\\s\\S]*?)(${key}:\\s*)([^,}\\n]+)([\\s\\S]*?\\}\\s*as\\s*const)`, 'g');
|
|
3971
|
+
return content.replace(objRegex, `$1$2${formattedValue}$4`);
|
|
3972
|
+
} else if (parts.length === 3) {
|
|
3973
|
+
// Nested case: PRICING.PRO.MONTHLY
|
|
3974
|
+
const [obj, subObj, key] = parts;
|
|
3975
|
+
|
|
3976
|
+
// More complex regex to find nested value
|
|
3977
|
+
// Look for the pattern within the specific sub-object
|
|
3978
|
+
const objPattern = `export const ${obj}\\s*=\\s*\\{[\\s\\S]*?${subObj}:\\s*\\{[\\s\\S]*?${key}:\\s*`;
|
|
3979
|
+
|
|
3980
|
+
const lines = content.split('\n');
|
|
3981
|
+
let inObject = false;
|
|
3982
|
+
let inSubObject = false;
|
|
3983
|
+
let braceDepth = 0;
|
|
3984
|
+
|
|
3985
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3986
|
+
const line = lines[i];
|
|
3987
|
+
|
|
3988
|
+
// Track if we're in the right object
|
|
3989
|
+
if (line.includes(`export const ${obj}`)) {
|
|
3990
|
+
inObject = true;
|
|
3991
|
+
}
|
|
3992
|
+
|
|
3993
|
+
if (inObject) {
|
|
3994
|
+
// Count braces
|
|
3995
|
+
braceDepth += (line.match(/\{/g) || []).length;
|
|
3996
|
+
braceDepth -= (line.match(/\}/g) || []).length;
|
|
3997
|
+
|
|
3998
|
+
// Check if we're in the sub-object
|
|
3999
|
+
if (line.includes(`${subObj}:`)) {
|
|
4000
|
+
inSubObject = true;
|
|
4001
|
+
}
|
|
4002
|
+
|
|
4003
|
+
if (inSubObject && line.includes(`${key}:`)) {
|
|
4004
|
+
// Found the line to update
|
|
4005
|
+
lines[i] = line.replace(
|
|
4006
|
+
new RegExp(`(${key}:\\s*)([^,}\\n]+)`),
|
|
4007
|
+
`$1${formattedValue}`
|
|
4008
|
+
);
|
|
4009
|
+
break;
|
|
4010
|
+
}
|
|
4011
|
+
|
|
4012
|
+
// Exit sub-object when we see closing brace at right depth
|
|
4013
|
+
if (inSubObject && line.includes('}') && !line.includes('{')) {
|
|
4014
|
+
// Check if this closes the sub-object
|
|
4015
|
+
const match = line.match(/^\s*\}/);
|
|
4016
|
+
if (match) {
|
|
4017
|
+
inSubObject = false;
|
|
4018
|
+
}
|
|
4019
|
+
}
|
|
4020
|
+
|
|
4021
|
+
if (braceDepth === 0) {
|
|
4022
|
+
inObject = false;
|
|
4023
|
+
}
|
|
4024
|
+
}
|
|
4025
|
+
}
|
|
4026
|
+
|
|
4027
|
+
return lines.join('\n');
|
|
4028
|
+
} else if (parts.length === 4) {
|
|
4029
|
+
// Deeply nested: API_KEYS.RATE_LIMIT.REQUESTS_PER_MINUTE
|
|
4030
|
+
const [obj, subObj1, subObj2, key] = parts;
|
|
4031
|
+
|
|
4032
|
+
const lines = content.split('\n');
|
|
4033
|
+
let inObject = false;
|
|
4034
|
+
let inSubObj1 = false;
|
|
4035
|
+
let inSubObj2 = false;
|
|
4036
|
+
|
|
4037
|
+
for (let i = 0; i < lines.length; i++) {
|
|
4038
|
+
const line = lines[i];
|
|
4039
|
+
|
|
4040
|
+
if (line.includes(`export const ${obj}`)) {
|
|
4041
|
+
inObject = true;
|
|
4042
|
+
}
|
|
4043
|
+
|
|
4044
|
+
if (inObject && line.includes(`${subObj1}:`)) {
|
|
4045
|
+
inSubObj1 = true;
|
|
4046
|
+
}
|
|
4047
|
+
|
|
4048
|
+
if (inSubObj1 && line.includes(`${subObj2}:`)) {
|
|
4049
|
+
inSubObj2 = true;
|
|
4050
|
+
}
|
|
4051
|
+
|
|
4052
|
+
if (inSubObj2 && line.includes(`${key}:`)) {
|
|
4053
|
+
lines[i] = line.replace(
|
|
4054
|
+
new RegExp(`(${key}:\\s*)([^,}\\n]+)`),
|
|
4055
|
+
`$1${formattedValue}`
|
|
4056
|
+
);
|
|
4057
|
+
break;
|
|
4058
|
+
}
|
|
4059
|
+
|
|
4060
|
+
// Reset on closing braces (simplified - could be more robust)
|
|
4061
|
+
if (inSubObj2 && line.trim() === '},') {
|
|
4062
|
+
inSubObj2 = false;
|
|
4063
|
+
}
|
|
4064
|
+
if (inSubObj1 && line.trim() === '},') {
|
|
4065
|
+
inSubObj1 = false;
|
|
4066
|
+
}
|
|
4067
|
+
}
|
|
4068
|
+
|
|
4069
|
+
return lines.join('\n');
|
|
4070
|
+
}
|
|
4071
|
+
|
|
4072
|
+
return content;
|
|
4073
|
+
}
|
|
4074
|
+
|
|
4075
|
+
/**
|
|
4076
|
+
* Handle update_schema tool - add/modify database tables via natural language
|
|
4077
|
+
*/
|
|
4078
|
+
private async handleUpdateSchema(args: { request: string }) {
|
|
4079
|
+
const { request } = args;
|
|
4080
|
+
const cwd = process.cwd();
|
|
4081
|
+
|
|
4082
|
+
// Find schema file
|
|
4083
|
+
const possiblePaths = [
|
|
4084
|
+
path.join(cwd, 'src', 'db', 'schema.ts'),
|
|
4085
|
+
path.join(cwd, 'db', 'schema.ts'),
|
|
4086
|
+
path.join(cwd, 'schema.ts'),
|
|
4087
|
+
path.join(cwd, 'drizzle', 'schema.ts'),
|
|
4088
|
+
];
|
|
4089
|
+
|
|
4090
|
+
let schemaPath: string | null = null;
|
|
4091
|
+
for (const p of possiblePaths) {
|
|
4092
|
+
if (fs.existsSync(p)) {
|
|
4093
|
+
schemaPath = p;
|
|
4094
|
+
break;
|
|
4095
|
+
}
|
|
4096
|
+
}
|
|
4097
|
+
|
|
4098
|
+
if (!schemaPath) {
|
|
4099
|
+
return {
|
|
4100
|
+
content: [{
|
|
4101
|
+
type: 'text' as const,
|
|
4102
|
+
text: `❌ Could not find schema.ts file.\n\nLooked in:\n${possiblePaths.map(p => `- ${p}`).join('\n')}\n\nMake sure you have a Drizzle schema file or create one at src/db/schema.ts`,
|
|
4103
|
+
}],
|
|
4104
|
+
isError: true,
|
|
4105
|
+
};
|
|
4106
|
+
}
|
|
4107
|
+
|
|
4108
|
+
// Parse the request to understand what to create
|
|
4109
|
+
const parsed = this.parseSchemaRequest(request);
|
|
4110
|
+
|
|
4111
|
+
if (!parsed.success) {
|
|
4112
|
+
return {
|
|
4113
|
+
content: [{
|
|
4114
|
+
type: 'text' as const,
|
|
4115
|
+
text: `❓ I couldn't understand that schema request.\n\n**Request:** "${request}"\n\n**Supported changes:**\n- "add a tags table with name and color fields"\n- "create a comments table with userId, postId, and content"\n- "add isArchived boolean to projects table"\n- "add createdAt timestamp to users"\n\n**Try rephrasing** with the table name and fields.`,
|
|
4116
|
+
}],
|
|
4117
|
+
};
|
|
4118
|
+
}
|
|
4119
|
+
|
|
4120
|
+
// Read current schema
|
|
4121
|
+
const currentSchema = fs.readFileSync(schemaPath, 'utf-8');
|
|
4122
|
+
|
|
4123
|
+
// Generate new schema code
|
|
4124
|
+
const { tableName, fields, action, description } = parsed;
|
|
4125
|
+
let newCode: string;
|
|
4126
|
+
let updatedSchema: string;
|
|
4127
|
+
|
|
4128
|
+
if (action === 'create_table') {
|
|
4129
|
+
newCode = this.generateTableSchema(tableName!, fields!);
|
|
4130
|
+
// Add to end of file before any exports
|
|
4131
|
+
const exportIndex = currentSchema.lastIndexOf('export {');
|
|
4132
|
+
if (exportIndex > 0) {
|
|
4133
|
+
updatedSchema = currentSchema.slice(0, exportIndex) + newCode + '\n\n' + currentSchema.slice(exportIndex);
|
|
4134
|
+
} else {
|
|
4135
|
+
updatedSchema = currentSchema + '\n\n' + newCode;
|
|
4136
|
+
}
|
|
4137
|
+
} else if (action === 'add_field') {
|
|
4138
|
+
// Find the table and add field
|
|
4139
|
+
const tableRegex = new RegExp(`export const ${tableName} = pgTable\\([^)]+\\{([\\s\\S]*?)\\}\\s*\\)`, 'g');
|
|
4140
|
+
const match = tableRegex.exec(currentSchema);
|
|
4141
|
+
if (!match) {
|
|
4142
|
+
return {
|
|
4143
|
+
content: [{
|
|
4144
|
+
type: 'text' as const,
|
|
4145
|
+
text: `❌ Could not find table "${tableName}" in schema.\n\nMake sure the table exists before adding fields.`,
|
|
4146
|
+
}],
|
|
4147
|
+
};
|
|
4148
|
+
}
|
|
4149
|
+
const fieldCode = this.generateFieldCode(fields![0]);
|
|
4150
|
+
// Insert before the closing brace
|
|
4151
|
+
const insertPos = match.index + match[0].lastIndexOf('}');
|
|
4152
|
+
updatedSchema = currentSchema.slice(0, insertPos) + fieldCode + ',\n ' + currentSchema.slice(insertPos);
|
|
4153
|
+
} else {
|
|
4154
|
+
return {
|
|
4155
|
+
content: [{
|
|
4156
|
+
type: 'text' as const,
|
|
4157
|
+
text: `❌ Unknown schema action: ${action}`,
|
|
4158
|
+
}],
|
|
4159
|
+
};
|
|
4160
|
+
}
|
|
4161
|
+
|
|
4162
|
+
// Write updated schema
|
|
4163
|
+
fs.writeFileSync(schemaPath, updatedSchema, 'utf-8');
|
|
4164
|
+
|
|
4165
|
+
// Generate migration reminder
|
|
4166
|
+
const migrationHint = `\n\n📝 **Next steps:**\n1. Run \`npx drizzle-kit generate\` to create migration\n2. Run \`npx drizzle-kit migrate\` to apply it`;
|
|
4167
|
+
|
|
4168
|
+
return {
|
|
4169
|
+
content: [{
|
|
4170
|
+
type: 'text' as const,
|
|
4171
|
+
text: `✅ **Schema Updated**\n\n**Change:** ${description}\n**File:** \`${path.relative(cwd, schemaPath)}\`${migrationHint}`,
|
|
4172
|
+
}],
|
|
4173
|
+
};
|
|
4174
|
+
}
|
|
4175
|
+
|
|
4176
|
+
private parseSchemaRequest(request: string): {
|
|
4177
|
+
success: boolean;
|
|
4178
|
+
action?: 'create_table' | 'add_field';
|
|
4179
|
+
tableName?: string;
|
|
4180
|
+
fields?: Array<{ name: string; type: string; nullable?: boolean; references?: string }>;
|
|
4181
|
+
description?: string;
|
|
4182
|
+
} {
|
|
4183
|
+
const lower = request.toLowerCase();
|
|
4184
|
+
|
|
4185
|
+
// Create table pattern: "add/create a X table with Y, Z fields"
|
|
4186
|
+
const createMatch = lower.match(
|
|
4187
|
+
/(?:add|create)\s+(?:a\s+)?(\w+)\s+table\s+(?:with\s+)?(.+)/i
|
|
4188
|
+
);
|
|
4189
|
+
if (createMatch) {
|
|
4190
|
+
const tableName = createMatch[1];
|
|
4191
|
+
const fieldsStr = createMatch[2];
|
|
4192
|
+
const fields = this.parseFields(fieldsStr);
|
|
4193
|
+
|
|
4194
|
+
return {
|
|
4195
|
+
success: true,
|
|
4196
|
+
action: 'create_table',
|
|
4197
|
+
tableName,
|
|
4198
|
+
fields,
|
|
4199
|
+
description: `Created "${tableName}" table with ${fields.length} fields`,
|
|
4200
|
+
};
|
|
4201
|
+
}
|
|
4202
|
+
|
|
4203
|
+
// Add field pattern: "add X to Y table"
|
|
4204
|
+
const addFieldMatch = lower.match(
|
|
4205
|
+
/add\s+(\w+)\s+(?:field|column)?\s*(?:to\s+)?(\w+)\s+table/i
|
|
4206
|
+
);
|
|
4207
|
+
if (addFieldMatch) {
|
|
4208
|
+
const fieldName = addFieldMatch[1];
|
|
4209
|
+
const tableName = addFieldMatch[2];
|
|
4210
|
+
|
|
4211
|
+
// Detect type from name
|
|
4212
|
+
let fieldType = 'text';
|
|
4213
|
+
if (fieldName.includes('id') || fieldName.includes('Id')) fieldType = 'uuid';
|
|
4214
|
+
else if (fieldName.includes('at') || fieldName.includes('At') || fieldName.includes('date')) fieldType = 'timestamp';
|
|
4215
|
+
else if (fieldName.includes('is') || fieldName.includes('Is') || fieldName.includes('has') || fieldName.includes('Has')) fieldType = 'boolean';
|
|
4216
|
+
else if (fieldName.includes('count') || fieldName.includes('Count') || fieldName.includes('num') || fieldName.includes('Num')) fieldType = 'integer';
|
|
4217
|
+
|
|
4218
|
+
return {
|
|
4219
|
+
success: true,
|
|
4220
|
+
action: 'add_field',
|
|
4221
|
+
tableName,
|
|
4222
|
+
fields: [{ name: fieldName, type: fieldType }],
|
|
4223
|
+
description: `Added "${fieldName}" field to "${tableName}" table`,
|
|
4224
|
+
};
|
|
4225
|
+
}
|
|
4226
|
+
|
|
4227
|
+
// Add typed field: "add isArchived boolean to projects"
|
|
4228
|
+
const typedFieldMatch = lower.match(
|
|
4229
|
+
/add\s+(\w+)\s+(boolean|text|integer|timestamp|uuid)\s+(?:to\s+)?(\w+)/i
|
|
4230
|
+
);
|
|
4231
|
+
if (typedFieldMatch) {
|
|
4232
|
+
return {
|
|
4233
|
+
success: true,
|
|
4234
|
+
action: 'add_field',
|
|
4235
|
+
tableName: typedFieldMatch[3],
|
|
4236
|
+
fields: [{ name: typedFieldMatch[1], type: typedFieldMatch[2] }],
|
|
4237
|
+
description: `Added "${typedFieldMatch[1]}" (${typedFieldMatch[2]}) to "${typedFieldMatch[3]}" table`,
|
|
4238
|
+
};
|
|
4239
|
+
}
|
|
4240
|
+
|
|
4241
|
+
return { success: false };
|
|
4242
|
+
}
|
|
4243
|
+
|
|
4244
|
+
private parseFields(fieldsStr: string): Array<{ name: string; type: string; nullable?: boolean; references?: string }> {
|
|
4245
|
+
const fields: Array<{ name: string; type: string; nullable?: boolean; references?: string }> = [];
|
|
4246
|
+
|
|
4247
|
+
// Split by "and" or ","
|
|
4248
|
+
const parts = fieldsStr.split(/,|\band\b/).map(s => s.trim()).filter(Boolean);
|
|
4249
|
+
|
|
4250
|
+
for (const part of parts) {
|
|
4251
|
+
const words = part.split(/\s+/);
|
|
4252
|
+
let name = words[0].replace(/[^a-zA-Z0-9_]/g, '');
|
|
4253
|
+
|
|
4254
|
+
// Skip common filler words
|
|
4255
|
+
if (['a', 'an', 'the', 'field', 'fields', 'column', 'columns'].includes(name)) {
|
|
4256
|
+
name = words[1]?.replace(/[^a-zA-Z0-9_]/g, '') || '';
|
|
4257
|
+
}
|
|
4258
|
+
|
|
4259
|
+
if (!name) continue;
|
|
4260
|
+
|
|
4261
|
+
// Detect type from name or explicit type
|
|
4262
|
+
let type = 'text';
|
|
4263
|
+
const fullPart = part.toLowerCase();
|
|
4264
|
+
|
|
4265
|
+
if (fullPart.includes('boolean') || fullPart.includes('bool')) type = 'boolean';
|
|
4266
|
+
else if (fullPart.includes('integer') || fullPart.includes('int') || fullPart.includes('number')) type = 'integer';
|
|
4267
|
+
else if (fullPart.includes('timestamp') || fullPart.includes('datetime') || fullPart.includes('date')) type = 'timestamp';
|
|
4268
|
+
else if (fullPart.includes('uuid')) type = 'uuid';
|
|
4269
|
+
else if (name.endsWith('Id') || name.endsWith('_id')) type = 'uuid';
|
|
4270
|
+
else if (name.startsWith('is') || name.startsWith('has') || name.startsWith('can')) type = 'boolean';
|
|
4271
|
+
else if (name.endsWith('At') || name.endsWith('_at') || name.includes('date') || name.includes('Date')) type = 'timestamp';
|
|
4272
|
+
else if (name.includes('count') || name.includes('Count') || name.includes('num') || name.includes('Num')) type = 'integer';
|
|
4273
|
+
|
|
4274
|
+
fields.push({ name, type });
|
|
4275
|
+
}
|
|
4276
|
+
|
|
4277
|
+
// Add default fields if creating a new table
|
|
4278
|
+
const hasId = fields.some(f => f.name === 'id');
|
|
4279
|
+
const hasCreatedAt = fields.some(f => f.name === 'createdAt' || f.name === 'created_at');
|
|
4280
|
+
|
|
4281
|
+
if (!hasId) {
|
|
4282
|
+
fields.unshift({ name: 'id', type: 'uuid' });
|
|
4283
|
+
}
|
|
4284
|
+
if (!hasCreatedAt) {
|
|
4285
|
+
fields.push({ name: 'createdAt', type: 'timestamp' });
|
|
4286
|
+
}
|
|
4287
|
+
|
|
4288
|
+
return fields;
|
|
4289
|
+
}
|
|
4290
|
+
|
|
4291
|
+
private generateTableSchema(tableName: string, fields: Array<{ name: string; type: string; nullable?: boolean }>): string {
|
|
4292
|
+
const fieldLines = fields.map(f => this.generateFieldCode(f)).join(',\n ');
|
|
4293
|
+
|
|
4294
|
+
return `export const ${tableName} = pgTable('${this.toSnakeCase(tableName)}', {
|
|
4295
|
+
${fieldLines},
|
|
4296
|
+
});`;
|
|
4297
|
+
}
|
|
4298
|
+
|
|
4299
|
+
private generateFieldCode(field: { name: string; type: string; nullable?: boolean }): string {
|
|
4300
|
+
const { name, type, nullable } = field;
|
|
4301
|
+
const snakeName = this.toSnakeCase(name);
|
|
4302
|
+
|
|
4303
|
+
let code = '';
|
|
4304
|
+
switch (type) {
|
|
4305
|
+
case 'uuid':
|
|
4306
|
+
if (name === 'id') {
|
|
4307
|
+
code = `${name}: uuid('${snakeName}').defaultRandom().primaryKey()`;
|
|
4308
|
+
} else {
|
|
4309
|
+
code = `${name}: uuid('${snakeName}')`;
|
|
4310
|
+
}
|
|
4311
|
+
break;
|
|
4312
|
+
case 'text':
|
|
4313
|
+
code = `${name}: text('${snakeName}')`;
|
|
4314
|
+
break;
|
|
4315
|
+
case 'boolean':
|
|
4316
|
+
code = `${name}: boolean('${snakeName}').default(false)`;
|
|
4317
|
+
break;
|
|
4318
|
+
case 'integer':
|
|
4319
|
+
code = `${name}: integer('${snakeName}')`;
|
|
4320
|
+
break;
|
|
4321
|
+
case 'timestamp':
|
|
4322
|
+
if (name === 'createdAt' || name === 'created_at') {
|
|
4323
|
+
code = `${name}: timestamp('${snakeName}').defaultNow()`;
|
|
4324
|
+
} else if (name === 'updatedAt' || name === 'updated_at') {
|
|
4325
|
+
code = `${name}: timestamp('${snakeName}').defaultNow()`;
|
|
4326
|
+
} else {
|
|
4327
|
+
code = `${name}: timestamp('${snakeName}')`;
|
|
4328
|
+
}
|
|
4329
|
+
break;
|
|
4330
|
+
default:
|
|
4331
|
+
code = `${name}: text('${snakeName}')`;
|
|
4332
|
+
}
|
|
4333
|
+
|
|
4334
|
+
if (nullable && !code.includes('.default')) {
|
|
4335
|
+
// Fields are nullable by default in Drizzle unless .notNull() is added
|
|
4336
|
+
}
|
|
4337
|
+
|
|
4338
|
+
return code;
|
|
4339
|
+
}
|
|
4340
|
+
|
|
4341
|
+
private toSnakeCase(str: string): string {
|
|
4342
|
+
return str.replace(/([A-Z])/g, '_$1').toLowerCase().replace(/^_/, '');
|
|
4343
|
+
}
|
|
4344
|
+
|
|
4345
|
+
/**
|
|
4346
|
+
* Handle update_env tool - add/update environment variables
|
|
4347
|
+
*/
|
|
4348
|
+
private async handleUpdateEnv(args: { request: string }) {
|
|
4349
|
+
const { request } = args;
|
|
4350
|
+
const cwd = process.cwd();
|
|
4351
|
+
|
|
4352
|
+
const parsed = this.parseEnvRequest(request);
|
|
4353
|
+
|
|
4354
|
+
if (!parsed.success || !parsed.variables || parsed.variables.length === 0) {
|
|
4355
|
+
return {
|
|
4356
|
+
content: [{
|
|
4357
|
+
type: 'text' as const,
|
|
4358
|
+
text: `❓ I couldn't understand that env request.\n\n**Request:** "${request}"\n\n**Supported:**\n- "add OPENAI_API_KEY"\n- "add Stripe keys" (adds STRIPE_SECRET_KEY and STRIPE_PUBLISHABLE_KEY)\n- "add RESEND_API_KEY for emails"\n- "add DATABASE_URL"\n\n**Try rephrasing** with the variable name(s).`,
|
|
4359
|
+
}],
|
|
4360
|
+
};
|
|
4361
|
+
}
|
|
4362
|
+
|
|
4363
|
+
const results: string[] = [];
|
|
4364
|
+
const { variables } = parsed;
|
|
4365
|
+
|
|
4366
|
+
// Update .env.local
|
|
4367
|
+
const envLocalPath = path.join(cwd, '.env.local');
|
|
4368
|
+
let envLocalContent = fs.existsSync(envLocalPath) ? fs.readFileSync(envLocalPath, 'utf-8') : '';
|
|
4369
|
+
|
|
4370
|
+
for (const v of variables) {
|
|
4371
|
+
if (!envLocalContent.includes(`${v.name}=`)) {
|
|
4372
|
+
envLocalContent += `\n${v.name}=${v.placeholder || ''}`;
|
|
4373
|
+
results.push(`✓ Added ${v.name} to .env.local`);
|
|
4374
|
+
} else {
|
|
4375
|
+
results.push(`⚠️ ${v.name} already exists in .env.local`);
|
|
4376
|
+
}
|
|
4377
|
+
}
|
|
4378
|
+
fs.writeFileSync(envLocalPath, envLocalContent.trim() + '\n', 'utf-8');
|
|
4379
|
+
|
|
4380
|
+
// Update .env.example
|
|
4381
|
+
const envExamplePath = path.join(cwd, '.env.example');
|
|
4382
|
+
let envExampleContent = fs.existsSync(envExamplePath) ? fs.readFileSync(envExamplePath, 'utf-8') : '';
|
|
4383
|
+
|
|
4384
|
+
for (const v of variables) {
|
|
4385
|
+
if (!envExampleContent.includes(`${v.name}=`)) {
|
|
4386
|
+
const comment = v.comment ? `# ${v.comment}\n` : '';
|
|
4387
|
+
envExampleContent += `\n${comment}${v.name}=`;
|
|
4388
|
+
results.push(`✓ Added ${v.name} to .env.example`);
|
|
4389
|
+
}
|
|
4390
|
+
}
|
|
4391
|
+
fs.writeFileSync(envExamplePath, envExampleContent.trim() + '\n', 'utf-8');
|
|
4392
|
+
|
|
4393
|
+
return {
|
|
4394
|
+
content: [{
|
|
4395
|
+
type: 'text' as const,
|
|
4396
|
+
text: `✅ **Environment Variables Updated**\n\n${results.join('\n')}\n\n📝 **Don't forget** to add the actual values to .env.local`,
|
|
4397
|
+
}],
|
|
4398
|
+
};
|
|
4399
|
+
}
|
|
4400
|
+
|
|
4401
|
+
private parseEnvRequest(request: string): {
|
|
4402
|
+
success: boolean;
|
|
4403
|
+
variables?: Array<{ name: string; placeholder?: string; comment?: string }>;
|
|
4404
|
+
} {
|
|
4405
|
+
const lower = request.toLowerCase();
|
|
4406
|
+
const variables: Array<{ name: string; placeholder?: string; comment?: string }> = [];
|
|
4407
|
+
|
|
4408
|
+
// Known service patterns
|
|
4409
|
+
if (lower.includes('stripe')) {
|
|
4410
|
+
variables.push(
|
|
4411
|
+
{ name: 'STRIPE_SECRET_KEY', placeholder: 'sk_test_...', comment: 'Stripe secret key' },
|
|
4412
|
+
{ name: 'STRIPE_PUBLISHABLE_KEY', placeholder: 'pk_test_...', comment: 'Stripe publishable key' },
|
|
4413
|
+
{ name: 'STRIPE_WEBHOOK_SECRET', placeholder: 'whsec_...', comment: 'Stripe webhook secret' },
|
|
4414
|
+
);
|
|
4415
|
+
}
|
|
4416
|
+
|
|
4417
|
+
if (lower.includes('openai')) {
|
|
4418
|
+
variables.push({ name: 'OPENAI_API_KEY', placeholder: 'sk-...', comment: 'OpenAI API key' });
|
|
4419
|
+
}
|
|
4420
|
+
|
|
4421
|
+
if (lower.includes('anthropic') || lower.includes('claude')) {
|
|
4422
|
+
variables.push({ name: 'ANTHROPIC_API_KEY', placeholder: 'sk-ant-...', comment: 'Anthropic API key' });
|
|
4423
|
+
}
|
|
4424
|
+
|
|
4425
|
+
if (lower.includes('resend') || lower.includes('email')) {
|
|
4426
|
+
variables.push({ name: 'RESEND_API_KEY', placeholder: 're_...', comment: 'Resend API key for emails' });
|
|
4427
|
+
}
|
|
4428
|
+
|
|
4429
|
+
if (lower.includes('supabase')) {
|
|
4430
|
+
variables.push(
|
|
4431
|
+
{ name: 'NEXT_PUBLIC_SUPABASE_URL', placeholder: 'https://xxx.supabase.co', comment: 'Supabase project URL' },
|
|
4432
|
+
{ name: 'NEXT_PUBLIC_SUPABASE_ANON_KEY', placeholder: 'eyJ...', comment: 'Supabase anon key' },
|
|
4433
|
+
{ name: 'SUPABASE_SERVICE_ROLE_KEY', placeholder: 'eyJ...', comment: 'Supabase service role key (server only)' },
|
|
4434
|
+
);
|
|
4435
|
+
}
|
|
4436
|
+
|
|
4437
|
+
if (lower.includes('database') || lower.includes('postgres') || lower.includes('db')) {
|
|
4438
|
+
variables.push({ name: 'DATABASE_URL', placeholder: 'postgresql://...', comment: 'PostgreSQL connection string' });
|
|
4439
|
+
}
|
|
4440
|
+
|
|
4441
|
+
if (lower.includes('github')) {
|
|
4442
|
+
variables.push(
|
|
4443
|
+
{ name: 'GITHUB_CLIENT_ID', placeholder: '', comment: 'GitHub OAuth client ID' },
|
|
4444
|
+
{ name: 'GITHUB_CLIENT_SECRET', placeholder: '', comment: 'GitHub OAuth client secret' },
|
|
4445
|
+
);
|
|
4446
|
+
}
|
|
4447
|
+
|
|
4448
|
+
if (lower.includes('vercel')) {
|
|
4449
|
+
variables.push({ name: 'VERCEL_TOKEN', placeholder: '', comment: 'Vercel API token' });
|
|
4450
|
+
}
|
|
4451
|
+
|
|
4452
|
+
// Extract explicit variable names from request
|
|
4453
|
+
const explicitMatch = request.match(/\b([A-Z][A-Z0-9_]{2,})\b/g);
|
|
4454
|
+
if (explicitMatch) {
|
|
4455
|
+
for (const name of explicitMatch) {
|
|
4456
|
+
if (!variables.some(v => v.name === name)) {
|
|
4457
|
+
variables.push({ name, placeholder: '', comment: `Added via CLI` });
|
|
4458
|
+
}
|
|
4459
|
+
}
|
|
4460
|
+
}
|
|
4461
|
+
|
|
4462
|
+
return {
|
|
4463
|
+
success: variables.length > 0,
|
|
4464
|
+
variables,
|
|
4465
|
+
};
|
|
4466
|
+
}
|
|
4467
|
+
|
|
4468
|
+
/**
|
|
4469
|
+
* Handle billing_action tool - subscription and billing management
|
|
4470
|
+
*/
|
|
4471
|
+
private async handleBillingAction(args: { action: string }) {
|
|
4472
|
+
const { action } = args;
|
|
4473
|
+
const lower = action.toLowerCase();
|
|
4474
|
+
|
|
4475
|
+
// Check current auth status
|
|
4476
|
+
const trialState = getTrialState();
|
|
4477
|
+
const hasApiKey = !!this.apiKey;
|
|
4478
|
+
|
|
4479
|
+
// Show subscription status
|
|
4480
|
+
if (lower.includes('show') || lower.includes('status') || lower.includes('check') || lower.includes('my')) {
|
|
4481
|
+
let status = '';
|
|
4482
|
+
|
|
4483
|
+
if (hasApiKey) {
|
|
4484
|
+
// Fetch subscription info from API
|
|
4485
|
+
try {
|
|
4486
|
+
const response = await fetch(`${this.apiUrl}/api/subscription/status`, {
|
|
4487
|
+
headers: this.getAuthHeaders(),
|
|
4488
|
+
});
|
|
4489
|
+
|
|
4490
|
+
if (response.ok) {
|
|
4491
|
+
const data = await response.json();
|
|
4492
|
+
status = `# 💳 Subscription Status\n\n`;
|
|
4493
|
+
status += `**Plan:** ${data.plan || 'Unknown'}\n`;
|
|
4494
|
+
status += `**Status:** ${data.status || 'Unknown'}\n`;
|
|
4495
|
+
if (data.seats) status += `**Seats:** ${data.usedSeats}/${data.seats}\n`;
|
|
4496
|
+
if (data.renewsAt) status += `**Renews:** ${new Date(data.renewsAt).toLocaleDateString()}\n`;
|
|
4497
|
+
} else {
|
|
4498
|
+
status = `# 💳 Subscription Status\n\n**Status:** Active (API key configured)\n\nVisit https://codebakers.ai/billing for details.`;
|
|
4499
|
+
}
|
|
4500
|
+
} catch {
|
|
4501
|
+
status = `# 💳 Subscription Status\n\n**Status:** Active (API key configured)\n\nVisit https://codebakers.ai/billing for details.`;
|
|
4502
|
+
}
|
|
4503
|
+
} else if (trialState) {
|
|
4504
|
+
const daysRemaining = getTrialDaysRemaining();
|
|
4505
|
+
const isExpired = isTrialExpired();
|
|
4506
|
+
|
|
4507
|
+
status = `# 🎁 Trial Status\n\n`;
|
|
4508
|
+
status += `**Stage:** ${trialState.stage}\n`;
|
|
4509
|
+
status += `**Days Remaining:** ${isExpired ? '0 (expired)' : daysRemaining}\n`;
|
|
4510
|
+
|
|
4511
|
+
if (trialState.stage === 'anonymous' && !isExpired) {
|
|
4512
|
+
status += `\n💡 **Extend your trial:** Run \`codebakers extend\` to connect GitHub and get 7 more days free.`;
|
|
4513
|
+
} else if (isExpired) {
|
|
4514
|
+
status += `\n⚠️ **Trial expired.** Run \`codebakers billing\` to upgrade.`;
|
|
4515
|
+
}
|
|
4516
|
+
} else {
|
|
4517
|
+
status = `# ❓ No Subscription\n\nRun \`codebakers go\` to start a free trial, or \`codebakers setup\` if you have an account.`;
|
|
4518
|
+
}
|
|
4519
|
+
|
|
4520
|
+
return {
|
|
4521
|
+
content: [{
|
|
4522
|
+
type: 'text' as const,
|
|
4523
|
+
text: status,
|
|
4524
|
+
}],
|
|
4525
|
+
};
|
|
4526
|
+
}
|
|
4527
|
+
|
|
4528
|
+
// Extend trial
|
|
4529
|
+
if (lower.includes('extend')) {
|
|
4530
|
+
if (!trialState) {
|
|
4531
|
+
return {
|
|
4532
|
+
content: [{
|
|
4533
|
+
type: 'text' as const,
|
|
4534
|
+
text: `❌ No active trial to extend.\n\nRun \`codebakers go\` to start a free trial first.`,
|
|
4535
|
+
}],
|
|
4536
|
+
};
|
|
4537
|
+
}
|
|
4538
|
+
|
|
4539
|
+
if (trialState.stage === 'extended') {
|
|
4540
|
+
return {
|
|
4541
|
+
content: [{
|
|
4542
|
+
type: 'text' as const,
|
|
4543
|
+
text: `⚠️ Trial already extended.\n\nYou've already connected GitHub. Run \`codebakers billing\` to upgrade to a paid plan.`,
|
|
4544
|
+
}],
|
|
4545
|
+
};
|
|
4546
|
+
}
|
|
4547
|
+
|
|
4548
|
+
return {
|
|
4549
|
+
content: [{
|
|
4550
|
+
type: 'text' as const,
|
|
4551
|
+
text: `# 🚀 Extend Your Trial\n\nRun this command to connect GitHub and get 7 more days free:\n\n\`\`\`\ncodebakers extend\n\`\`\`\n\nOr visit: https://codebakers.ai/api/auth/github?extend=true`,
|
|
4552
|
+
}],
|
|
4553
|
+
};
|
|
4554
|
+
}
|
|
4555
|
+
|
|
4556
|
+
// Upgrade
|
|
4557
|
+
if (lower.includes('upgrade') || lower.includes('pro') || lower.includes('team') || lower.includes('agency')) {
|
|
4558
|
+
let plan = 'pro';
|
|
4559
|
+
if (lower.includes('team')) plan = 'team';
|
|
4560
|
+
if (lower.includes('agency')) plan = 'agency';
|
|
4561
|
+
|
|
4562
|
+
return {
|
|
4563
|
+
content: [{
|
|
4564
|
+
type: 'text' as const,
|
|
4565
|
+
text: `# 💎 Upgrade to ${plan.charAt(0).toUpperCase() + plan.slice(1)}\n\nRun this command to open billing:\n\n\`\`\`\ncodebakers billing\n\`\`\`\n\nOr visit: https://codebakers.ai/billing?plan=${plan}`,
|
|
4566
|
+
}],
|
|
4567
|
+
};
|
|
4568
|
+
}
|
|
4569
|
+
|
|
4570
|
+
// Default: show help
|
|
4571
|
+
return {
|
|
4572
|
+
content: [{
|
|
4573
|
+
type: 'text' as const,
|
|
4574
|
+
text: `# 💳 Billing Actions\n\n**Available commands:**\n- "show my subscription" - View current status\n- "extend trial" - Get 7 more days with GitHub\n- "upgrade to Pro" - Open upgrade page\n- "upgrade to Team" - Open Team plan page\n\nOr run \`codebakers billing\` to open the billing page.`,
|
|
4575
|
+
}],
|
|
4576
|
+
};
|
|
4577
|
+
}
|
|
4578
|
+
|
|
4579
|
+
/**
|
|
4580
|
+
* Handle add_page tool - create new Next.js pages
|
|
4581
|
+
*/
|
|
4582
|
+
private async handleAddPage(args: { request: string }) {
|
|
4583
|
+
const { request } = args;
|
|
4584
|
+
const cwd = process.cwd();
|
|
4585
|
+
|
|
4586
|
+
const parsed = this.parsePageRequest(request);
|
|
4587
|
+
|
|
4588
|
+
if (!parsed.success) {
|
|
4589
|
+
return {
|
|
4590
|
+
content: [{
|
|
4591
|
+
type: 'text' as const,
|
|
4592
|
+
text: `❓ I couldn't understand that page request.\n\n**Request:** "${request}"\n\n**Supported:**\n- "create a settings page"\n- "add an about page"\n- "make a user profile page"\n- "create a dashboard page with stats"\n\n**Try rephrasing** with the page name and optional features.`,
|
|
4593
|
+
}],
|
|
4594
|
+
};
|
|
4595
|
+
}
|
|
4596
|
+
|
|
4597
|
+
const { pageName, route, features, isProtected } = parsed;
|
|
4598
|
+
|
|
4599
|
+
// Determine the correct app directory
|
|
4600
|
+
const appDir = path.join(cwd, 'src', 'app');
|
|
4601
|
+
if (!fs.existsSync(appDir)) {
|
|
4602
|
+
return {
|
|
4603
|
+
content: [{
|
|
4604
|
+
type: 'text' as const,
|
|
4605
|
+
text: `❌ Could not find src/app directory.\n\nMake sure you're in a Next.js project root.`,
|
|
4606
|
+
}],
|
|
4607
|
+
isError: true,
|
|
4608
|
+
};
|
|
4609
|
+
}
|
|
4610
|
+
|
|
4611
|
+
// Determine route group
|
|
4612
|
+
let targetDir: string;
|
|
4613
|
+
if (isProtected) {
|
|
4614
|
+
targetDir = path.join(appDir, '(dashboard)', route!);
|
|
4615
|
+
} else {
|
|
4616
|
+
targetDir = path.join(appDir, '(marketing)', route!);
|
|
4617
|
+
}
|
|
4618
|
+
|
|
4619
|
+
// Create directory if needed
|
|
4620
|
+
if (!fs.existsSync(targetDir)) {
|
|
4621
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
4622
|
+
}
|
|
4623
|
+
|
|
4624
|
+
// Generate page content
|
|
4625
|
+
const pageContent = this.generatePageContent(pageName!, features || [], isProtected || false);
|
|
4626
|
+
|
|
4627
|
+
// Write page file
|
|
4628
|
+
const pagePath = path.join(targetDir, 'page.tsx');
|
|
4629
|
+
fs.writeFileSync(pagePath, pageContent, 'utf-8');
|
|
4630
|
+
|
|
4631
|
+
return {
|
|
4632
|
+
content: [{
|
|
4633
|
+
type: 'text' as const,
|
|
4634
|
+
text: `✅ **Page Created**\n\n**Name:** ${pageName}\n**Route:** /${route}\n**File:** \`${path.relative(cwd, pagePath)}\`\n**Protected:** ${isProtected ? 'Yes (requires auth)' : 'No (public)'}\n\n📝 **Next steps:**\n1. Customize the page content\n2. Add to navigation if needed`,
|
|
4635
|
+
}],
|
|
4636
|
+
};
|
|
4637
|
+
}
|
|
4638
|
+
|
|
4639
|
+
private parsePageRequest(request: string): {
|
|
4640
|
+
success: boolean;
|
|
4641
|
+
pageName?: string;
|
|
4642
|
+
route?: string;
|
|
4643
|
+
features?: string[];
|
|
4644
|
+
isProtected?: boolean;
|
|
4645
|
+
} {
|
|
4646
|
+
const lower = request.toLowerCase();
|
|
4647
|
+
|
|
4648
|
+
// Extract page name
|
|
4649
|
+
const pageMatch = lower.match(
|
|
4650
|
+
/(?:create|add|make)\s+(?:a\s+)?(\w+)\s+page/i
|
|
4651
|
+
);
|
|
4652
|
+
|
|
4653
|
+
if (!pageMatch) return { success: false };
|
|
4654
|
+
|
|
4655
|
+
const pageName = pageMatch[1];
|
|
4656
|
+
const route = pageName.toLowerCase();
|
|
4657
|
+
|
|
4658
|
+
// Detect features
|
|
4659
|
+
const features: string[] = [];
|
|
4660
|
+
if (lower.includes('tab')) features.push('tabs');
|
|
4661
|
+
if (lower.includes('form')) features.push('form');
|
|
4662
|
+
if (lower.includes('stat')) features.push('stats');
|
|
4663
|
+
if (lower.includes('table') || lower.includes('list')) features.push('table');
|
|
4664
|
+
if (lower.includes('card')) features.push('cards');
|
|
4665
|
+
|
|
4666
|
+
// Detect if protected
|
|
4667
|
+
const protectedKeywords = ['dashboard', 'settings', 'profile', 'account', 'admin', 'billing'];
|
|
4668
|
+
const isProtected = protectedKeywords.some(k => lower.includes(k));
|
|
4669
|
+
|
|
4670
|
+
return {
|
|
4671
|
+
success: true,
|
|
4672
|
+
pageName: pageName.charAt(0).toUpperCase() + pageName.slice(1),
|
|
4673
|
+
route,
|
|
4674
|
+
features,
|
|
4675
|
+
isProtected,
|
|
4676
|
+
};
|
|
4677
|
+
}
|
|
4678
|
+
|
|
4679
|
+
private generatePageContent(pageName: string, features: string[], isProtected: boolean): string {
|
|
4680
|
+
const imports: string[] = [];
|
|
4681
|
+
const components: string[] = [];
|
|
4682
|
+
|
|
4683
|
+
if (isProtected) {
|
|
4684
|
+
imports.push(`import { redirect } from 'next/navigation';`);
|
|
4685
|
+
imports.push(`import { getServerSession } from 'next-auth';`);
|
|
4686
|
+
}
|
|
4687
|
+
|
|
4688
|
+
if (features.includes('tabs')) {
|
|
4689
|
+
imports.push(`import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';`);
|
|
4690
|
+
}
|
|
4691
|
+
|
|
4692
|
+
if (features.includes('cards') || features.includes('stats')) {
|
|
4693
|
+
imports.push(`import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';`);
|
|
4694
|
+
}
|
|
4695
|
+
|
|
4696
|
+
// Generate component based on features
|
|
4697
|
+
let content = '';
|
|
4698
|
+
|
|
4699
|
+
if (features.includes('stats')) {
|
|
4700
|
+
content = `
|
|
4701
|
+
<div className="grid gap-4 md:grid-cols-3">
|
|
4702
|
+
<Card>
|
|
4703
|
+
<CardHeader>
|
|
4704
|
+
<CardTitle>Total Users</CardTitle>
|
|
4705
|
+
</CardHeader>
|
|
4706
|
+
<CardContent>
|
|
4707
|
+
<p className="text-2xl font-bold">1,234</p>
|
|
4708
|
+
</CardContent>
|
|
4709
|
+
</Card>
|
|
4710
|
+
<Card>
|
|
4711
|
+
<CardHeader>
|
|
4712
|
+
<CardTitle>Revenue</CardTitle>
|
|
4713
|
+
</CardHeader>
|
|
4714
|
+
<CardContent>
|
|
4715
|
+
<p className="text-2xl font-bold">$12,345</p>
|
|
4716
|
+
</CardContent>
|
|
4717
|
+
</Card>
|
|
4718
|
+
<Card>
|
|
4719
|
+
<CardHeader>
|
|
4720
|
+
<CardTitle>Active Projects</CardTitle>
|
|
4721
|
+
</CardHeader>
|
|
4722
|
+
<CardContent>
|
|
4723
|
+
<p className="text-2xl font-bold">42</p>
|
|
4724
|
+
</CardContent>
|
|
4725
|
+
</Card>
|
|
4726
|
+
</div>`;
|
|
4727
|
+
} else if (features.includes('tabs')) {
|
|
4728
|
+
content = `
|
|
4729
|
+
<Tabs defaultValue="general" className="w-full">
|
|
4730
|
+
<TabsList>
|
|
4731
|
+
<TabsTrigger value="general">General</TabsTrigger>
|
|
4732
|
+
<TabsTrigger value="notifications">Notifications</TabsTrigger>
|
|
4733
|
+
<TabsTrigger value="security">Security</TabsTrigger>
|
|
4734
|
+
</TabsList>
|
|
4735
|
+
<TabsContent value="general">
|
|
4736
|
+
<Card>
|
|
4737
|
+
<CardHeader>
|
|
4738
|
+
<CardTitle>General Settings</CardTitle>
|
|
4739
|
+
<CardDescription>Manage your general preferences</CardDescription>
|
|
4740
|
+
</CardHeader>
|
|
4741
|
+
<CardContent>
|
|
4742
|
+
{/* Add form fields here */}
|
|
4743
|
+
</CardContent>
|
|
4744
|
+
</Card>
|
|
4745
|
+
</TabsContent>
|
|
4746
|
+
<TabsContent value="notifications">
|
|
4747
|
+
<Card>
|
|
4748
|
+
<CardHeader>
|
|
4749
|
+
<CardTitle>Notification Settings</CardTitle>
|
|
4750
|
+
</CardHeader>
|
|
4751
|
+
<CardContent>
|
|
4752
|
+
{/* Add notification settings here */}
|
|
4753
|
+
</CardContent>
|
|
4754
|
+
</Card>
|
|
4755
|
+
</TabsContent>
|
|
4756
|
+
<TabsContent value="security">
|
|
4757
|
+
<Card>
|
|
4758
|
+
<CardHeader>
|
|
4759
|
+
<CardTitle>Security Settings</CardTitle>
|
|
4760
|
+
</CardHeader>
|
|
4761
|
+
<CardContent>
|
|
4762
|
+
{/* Add security settings here */}
|
|
4763
|
+
</CardContent>
|
|
4764
|
+
</Card>
|
|
4765
|
+
</TabsContent>
|
|
4766
|
+
</Tabs>`;
|
|
4767
|
+
} else {
|
|
4768
|
+
content = `
|
|
4769
|
+
<p className="text-muted-foreground">
|
|
4770
|
+
Welcome to the ${pageName} page. Add your content here.
|
|
4771
|
+
</p>`;
|
|
4772
|
+
}
|
|
4773
|
+
|
|
4774
|
+
const asyncKeyword = isProtected ? 'async ' : '';
|
|
4775
|
+
const authCheck = isProtected ? `
|
|
4776
|
+
const session = await getServerSession();
|
|
4777
|
+
if (!session) {
|
|
4778
|
+
redirect('/login');
|
|
4779
|
+
}
|
|
4780
|
+
` : '';
|
|
4781
|
+
|
|
4782
|
+
return `${imports.join('\n')}
|
|
4783
|
+
|
|
4784
|
+
export const metadata = {
|
|
4785
|
+
title: '${pageName}',
|
|
4786
|
+
};
|
|
4787
|
+
|
|
4788
|
+
export default ${asyncKeyword}function ${pageName}Page() {${authCheck}
|
|
4789
|
+
return (
|
|
4790
|
+
<div className="container mx-auto py-8">
|
|
4791
|
+
<h1 className="text-3xl font-bold mb-8">${pageName}</h1>
|
|
4792
|
+
${content}
|
|
4793
|
+
</div>
|
|
4794
|
+
);
|
|
4795
|
+
}
|
|
4796
|
+
`;
|
|
4797
|
+
}
|
|
4798
|
+
|
|
4799
|
+
/**
|
|
4800
|
+
* Handle add_api_route tool - create new API endpoints
|
|
4801
|
+
*/
|
|
4802
|
+
private async handleAddApiRoute(args: { request: string }) {
|
|
4803
|
+
const { request } = args;
|
|
4804
|
+
const cwd = process.cwd();
|
|
4805
|
+
|
|
4806
|
+
const parsed = this.parseApiRouteRequest(request);
|
|
4807
|
+
|
|
4808
|
+
if (!parsed.success) {
|
|
4809
|
+
return {
|
|
4810
|
+
content: [{
|
|
4811
|
+
type: 'text' as const,
|
|
4812
|
+
text: `❓ I couldn't understand that API route request.\n\n**Request:** "${request}"\n\n**Supported:**\n- "create a feedback endpoint"\n- "add POST endpoint for user settings"\n- "make a webhook for Stripe"\n- "create GET/POST for preferences"\n\n**Try rephrasing** with the endpoint name and HTTP methods.`,
|
|
4813
|
+
}],
|
|
4814
|
+
};
|
|
4815
|
+
}
|
|
4816
|
+
|
|
4817
|
+
const { routeName, routePath, methods, isWebhook, requiresAuth } = parsed;
|
|
4818
|
+
|
|
4819
|
+
// Determine the API directory
|
|
4820
|
+
const apiDir = path.join(cwd, 'src', 'app', 'api', routePath!);
|
|
4821
|
+
|
|
4822
|
+
// Create directory if needed
|
|
4823
|
+
if (!fs.existsSync(apiDir)) {
|
|
4824
|
+
fs.mkdirSync(apiDir, { recursive: true });
|
|
4825
|
+
}
|
|
4826
|
+
|
|
4827
|
+
// Generate route content
|
|
4828
|
+
const routeContent = this.generateApiRouteContent(routeName!, methods!, isWebhook, requiresAuth);
|
|
4829
|
+
|
|
4830
|
+
// Write route file
|
|
4831
|
+
const routeFilePath = path.join(apiDir, 'route.ts');
|
|
4832
|
+
fs.writeFileSync(routeFilePath, routeContent, 'utf-8');
|
|
4833
|
+
|
|
4834
|
+
return {
|
|
4835
|
+
content: [{
|
|
4836
|
+
type: 'text' as const,
|
|
4837
|
+
text: `✅ **API Route Created**\n\n**Name:** ${routeName}\n**Path:** /api/${routePath}\n**Methods:** ${methods!.join(', ')}\n**File:** \`${path.relative(cwd, routeFilePath)}\`\n**Auth Required:** ${requiresAuth ? 'Yes' : 'No'}\n\n📝 **Next steps:**\n1. Implement the business logic\n2. Add validation with Zod\n3. Test the endpoint`,
|
|
4838
|
+
}],
|
|
4839
|
+
};
|
|
4840
|
+
}
|
|
4841
|
+
|
|
4842
|
+
private parseApiRouteRequest(request: string): {
|
|
4843
|
+
success: boolean;
|
|
4844
|
+
routeName?: string;
|
|
4845
|
+
routePath?: string;
|
|
4846
|
+
methods?: string[];
|
|
4847
|
+
isWebhook?: boolean;
|
|
4848
|
+
requiresAuth?: boolean;
|
|
4849
|
+
} {
|
|
4850
|
+
const lower = request.toLowerCase();
|
|
4851
|
+
|
|
4852
|
+
// Detect webhook
|
|
4853
|
+
const isWebhook = lower.includes('webhook');
|
|
4854
|
+
|
|
4855
|
+
// Extract route name
|
|
4856
|
+
let routeName = '';
|
|
4857
|
+
let routePath = '';
|
|
4858
|
+
|
|
4859
|
+
// Pattern: "create a X endpoint" or "add X route"
|
|
4860
|
+
const nameMatch = lower.match(
|
|
4861
|
+
/(?:create|add|make)\s+(?:a\s+)?(?:post\s+|get\s+)?(?:endpoint|route|api)?\s*(?:for\s+)?(\w+)/i
|
|
4862
|
+
);
|
|
4863
|
+
|
|
4864
|
+
if (nameMatch) {
|
|
4865
|
+
routeName = nameMatch[1];
|
|
4866
|
+
routePath = routeName.toLowerCase();
|
|
4867
|
+
} else {
|
|
4868
|
+
return { success: false };
|
|
4869
|
+
}
|
|
4870
|
+
|
|
4871
|
+
// Detect methods
|
|
4872
|
+
const methods: string[] = [];
|
|
4873
|
+
if (lower.includes('get')) methods.push('GET');
|
|
4874
|
+
if (lower.includes('post')) methods.push('POST');
|
|
4875
|
+
if (lower.includes('put')) methods.push('PUT');
|
|
4876
|
+
if (lower.includes('patch')) methods.push('PATCH');
|
|
4877
|
+
if (lower.includes('delete')) methods.push('DELETE');
|
|
4878
|
+
|
|
4879
|
+
// Default to POST for most endpoints, GET for queries
|
|
4880
|
+
if (methods.length === 0) {
|
|
4881
|
+
if (lower.includes('list') || lower.includes('fetch') || lower.includes('query')) {
|
|
4882
|
+
methods.push('GET');
|
|
4883
|
+
} else {
|
|
4884
|
+
methods.push('POST');
|
|
4885
|
+
}
|
|
4886
|
+
}
|
|
4887
|
+
|
|
4888
|
+
// Detect if auth required
|
|
4889
|
+
const noAuthKeywords = ['webhook', 'public', 'open'];
|
|
4890
|
+
const requiresAuth = !noAuthKeywords.some(k => lower.includes(k));
|
|
4891
|
+
|
|
4892
|
+
return {
|
|
4893
|
+
success: true,
|
|
4894
|
+
routeName: routeName.charAt(0).toUpperCase() + routeName.slice(1),
|
|
4895
|
+
routePath,
|
|
4896
|
+
methods,
|
|
4897
|
+
isWebhook,
|
|
4898
|
+
requiresAuth,
|
|
4899
|
+
};
|
|
4900
|
+
}
|
|
4901
|
+
|
|
4902
|
+
private generateApiRouteContent(routeName: string, methods: string[], isWebhook?: boolean, requiresAuth?: boolean): string {
|
|
4903
|
+
const imports: string[] = [
|
|
4904
|
+
`import { NextRequest, NextResponse } from 'next/server';`,
|
|
4905
|
+
`import { z } from 'zod';`,
|
|
4906
|
+
];
|
|
4907
|
+
|
|
4908
|
+
if (requiresAuth && !isWebhook) {
|
|
4909
|
+
imports.push(`import { getServerSession } from 'next-auth';`);
|
|
4910
|
+
}
|
|
4911
|
+
|
|
4912
|
+
const handlers: string[] = [];
|
|
4913
|
+
|
|
4914
|
+
for (const method of methods) {
|
|
4915
|
+
let handler = '';
|
|
4916
|
+
|
|
4917
|
+
if (method === 'GET') {
|
|
4918
|
+
handler = `
|
|
4919
|
+
export async function GET(request: NextRequest) {
|
|
4920
|
+
try {${requiresAuth ? `
|
|
4921
|
+
const session = await getServerSession();
|
|
4922
|
+
if (!session) {
|
|
4923
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
4924
|
+
}
|
|
4925
|
+
` : ''}
|
|
4926
|
+
// TODO: Implement ${routeName} GET logic
|
|
4927
|
+
|
|
4928
|
+
return NextResponse.json({ message: 'Success' });
|
|
4929
|
+
} catch (error) {
|
|
4930
|
+
console.error('[${routeName.toUpperCase()}] Error:', error);
|
|
4931
|
+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
4932
|
+
}
|
|
4933
|
+
}`;
|
|
4934
|
+
} else if (method === 'POST') {
|
|
4935
|
+
const schemaName = `${routeName}Schema`;
|
|
4936
|
+
|
|
4937
|
+
handler = `
|
|
4938
|
+
const ${schemaName} = z.object({
|
|
4939
|
+
// TODO: Define your schema
|
|
4940
|
+
// example: z.string().min(1),
|
|
4941
|
+
});
|
|
4942
|
+
|
|
4943
|
+
export async function POST(request: NextRequest) {
|
|
4944
|
+
try {${requiresAuth ? `
|
|
4945
|
+
const session = await getServerSession();
|
|
4946
|
+
if (!session) {
|
|
4947
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
4948
|
+
}
|
|
4949
|
+
` : ''}
|
|
4950
|
+
const body = await request.json();
|
|
4951
|
+
const validated = ${schemaName}.safeParse(body);
|
|
4952
|
+
|
|
4953
|
+
if (!validated.success) {
|
|
4954
|
+
return NextResponse.json({ error: 'Invalid request', details: validated.error.flatten() }, { status: 400 });
|
|
4955
|
+
}
|
|
4956
|
+
|
|
4957
|
+
// TODO: Implement ${routeName} POST logic
|
|
4958
|
+
|
|
4959
|
+
return NextResponse.json({ message: 'Success' }, { status: 201 });
|
|
4960
|
+
} catch (error) {
|
|
4961
|
+
console.error('[${routeName.toUpperCase()}] Error:', error);
|
|
4962
|
+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
4963
|
+
}
|
|
4964
|
+
}`;
|
|
4965
|
+
} else if (method === 'PUT' || method === 'PATCH') {
|
|
4966
|
+
handler = `
|
|
4967
|
+
export async function ${method}(request: NextRequest) {
|
|
4968
|
+
try {${requiresAuth ? `
|
|
4969
|
+
const session = await getServerSession();
|
|
4970
|
+
if (!session) {
|
|
4971
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
4972
|
+
}
|
|
4973
|
+
` : ''}
|
|
4974
|
+
const body = await request.json();
|
|
4975
|
+
|
|
4976
|
+
// TODO: Implement ${routeName} ${method} logic
|
|
4977
|
+
|
|
4978
|
+
return NextResponse.json({ message: 'Updated' });
|
|
4979
|
+
} catch (error) {
|
|
4980
|
+
console.error('[${routeName.toUpperCase()}] Error:', error);
|
|
4981
|
+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
4982
|
+
}
|
|
4983
|
+
}`;
|
|
4984
|
+
} else if (method === 'DELETE') {
|
|
4985
|
+
handler = `
|
|
4986
|
+
export async function DELETE(request: NextRequest) {
|
|
4987
|
+
try {${requiresAuth ? `
|
|
4988
|
+
const session = await getServerSession();
|
|
4989
|
+
if (!session) {
|
|
4990
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
4991
|
+
}
|
|
4992
|
+
` : ''}
|
|
4993
|
+
// TODO: Implement ${routeName} DELETE logic
|
|
4994
|
+
|
|
4995
|
+
return NextResponse.json({ message: 'Deleted' });
|
|
4996
|
+
} catch (error) {
|
|
4997
|
+
console.error('[${routeName.toUpperCase()}] Error:', error);
|
|
4998
|
+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
4999
|
+
}
|
|
5000
|
+
}`;
|
|
5001
|
+
}
|
|
5002
|
+
|
|
5003
|
+
handlers.push(handler);
|
|
5004
|
+
}
|
|
5005
|
+
|
|
5006
|
+
// Webhook-specific template
|
|
5007
|
+
if (isWebhook) {
|
|
5008
|
+
return `import { NextRequest, NextResponse } from 'next/server';
|
|
5009
|
+
import { headers } from 'next/headers';
|
|
5010
|
+
|
|
5011
|
+
export async function POST(request: NextRequest) {
|
|
5012
|
+
try {
|
|
5013
|
+
const body = await request.text();
|
|
5014
|
+
const headersList = headers();
|
|
5015
|
+
|
|
5016
|
+
// TODO: Verify webhook signature
|
|
5017
|
+
// const signature = headersList.get('stripe-signature');
|
|
5018
|
+
|
|
5019
|
+
// TODO: Parse and handle webhook event
|
|
5020
|
+
const event = JSON.parse(body);
|
|
5021
|
+
|
|
5022
|
+
switch (event.type) {
|
|
5023
|
+
case 'example.event':
|
|
5024
|
+
// Handle event
|
|
5025
|
+
break;
|
|
5026
|
+
default:
|
|
5027
|
+
console.log(\`Unhandled event type: \${event.type}\`);
|
|
5028
|
+
}
|
|
5029
|
+
|
|
5030
|
+
return NextResponse.json({ received: true });
|
|
5031
|
+
} catch (error) {
|
|
5032
|
+
console.error('[WEBHOOK] Error:', error);
|
|
5033
|
+
return NextResponse.json({ error: 'Webhook error' }, { status: 400 });
|
|
5034
|
+
}
|
|
5035
|
+
}
|
|
5036
|
+
`;
|
|
5037
|
+
}
|
|
5038
|
+
|
|
5039
|
+
return `${imports.join('\n')}
|
|
5040
|
+
${handlers.join('\n')}
|
|
5041
|
+
`;
|
|
5042
|
+
}
|
|
5043
|
+
|
|
3537
5044
|
async run(): Promise<void> {
|
|
3538
5045
|
const transport = new StdioServerTransport();
|
|
3539
5046
|
await this.server.connect(transport);
|