@latentforce/shift 1.0.0 → 1.0.2
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/README.md +108 -64
- package/build/cli/commands/init.js +136 -0
- package/build/cli/commands/start.js +81 -0
- package/build/cli/commands/status.js +46 -0
- package/build/cli/commands/stop.js +18 -0
- package/build/daemon/daemon-manager.js +136 -0
- package/build/daemon/daemon.js +119 -0
- package/build/daemon/tools-executor.js +383 -0
- package/build/daemon/websocket-client.js +334 -0
- package/build/index.js +39 -126
- package/build/mcp-server.js +124 -0
- package/build/utils/api-client.js +50 -0
- package/build/utils/config.js +165 -0
- package/build/utils/prompts.js +50 -0
- package/build/utils/tree-scanner.js +148 -0
- package/package.json +5 -2
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { EventEmitter } from 'events';
|
|
3
|
+
import { WS_URL } from '../utils/config.js';
|
|
4
|
+
import { ToolsExecutor } from './tools-executor.js';
|
|
5
|
+
const RECONNECT_INTERVAL = 5000; // 5 seconds - matching extension
|
|
6
|
+
const MAX_RECONNECT_ATTEMPTS = 100; // matching extension
|
|
7
|
+
/**
|
|
8
|
+
* WebSocket client matching extension's websocket-client.js
|
|
9
|
+
*/
|
|
10
|
+
export class WebSocketClient extends EventEmitter {
|
|
11
|
+
ws = null;
|
|
12
|
+
authenticated = false;
|
|
13
|
+
projectInfo = null;
|
|
14
|
+
userInfo = null;
|
|
15
|
+
toolsExecutor = null;
|
|
16
|
+
shouldReconnect = true;
|
|
17
|
+
reconnectTimer = null;
|
|
18
|
+
apiKey;
|
|
19
|
+
projectId;
|
|
20
|
+
wsUrl;
|
|
21
|
+
isConnecting = false;
|
|
22
|
+
reconnectAttempts = 0;
|
|
23
|
+
maxReconnectAttempts = MAX_RECONNECT_ATTEMPTS;
|
|
24
|
+
workspaceRoot;
|
|
25
|
+
constructor(options) {
|
|
26
|
+
super();
|
|
27
|
+
this.apiKey = options.apiKey;
|
|
28
|
+
this.projectId = options.projectId;
|
|
29
|
+
this.wsUrl = options.wsUrl || WS_URL;
|
|
30
|
+
this.workspaceRoot = options.workspaceRoot || process.cwd();
|
|
31
|
+
// Initialize tools executor
|
|
32
|
+
this.toolsExecutor = new ToolsExecutor(this, this.workspaceRoot);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Set the tools executor
|
|
36
|
+
*/
|
|
37
|
+
setToolsExecutor(toolsExecutor) {
|
|
38
|
+
this.toolsExecutor = toolsExecutor;
|
|
39
|
+
console.log('[WS-Client] Tools executor set');
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Connect to WebSocket server
|
|
43
|
+
*/
|
|
44
|
+
async connect() {
|
|
45
|
+
if (!this.apiKey || !this.projectId) {
|
|
46
|
+
throw new Error('API key and project ID are required');
|
|
47
|
+
}
|
|
48
|
+
this.shouldReconnect = true;
|
|
49
|
+
this.reconnectAttempts = 0;
|
|
50
|
+
console.log(`[WS-Client] Initiating connection to project: ${this.projectId}`);
|
|
51
|
+
return this._attemptConnection();
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Internal method to attempt connection
|
|
55
|
+
*/
|
|
56
|
+
async _attemptConnection() {
|
|
57
|
+
if (this.isConnecting) {
|
|
58
|
+
console.log('[WS-Client] Connection attempt already in progress, skipping...');
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
62
|
+
console.error(`[WS-Client] Max reconnection attempts (${this.maxReconnectAttempts}) reached. Giving up.`);
|
|
63
|
+
this.emit('max_reconnects_reached');
|
|
64
|
+
this.shouldReconnect = false;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
this.isConnecting = true;
|
|
68
|
+
this.reconnectAttempts++;
|
|
69
|
+
// Emit reconnecting event
|
|
70
|
+
this.emit('reconnecting', this.reconnectAttempts);
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
// IMPORTANT: Use project_id in URL, matching extension
|
|
73
|
+
const wsUrl = `${this.wsUrl}/ws/extension/${this.projectId}`;
|
|
74
|
+
console.log(`[WS-Client] Connecting to ${wsUrl} (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
|
75
|
+
try {
|
|
76
|
+
this.ws = new WebSocket(wsUrl);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
console.error('[WS-Client] Failed to create WebSocket:', error);
|
|
80
|
+
this.isConnecting = false;
|
|
81
|
+
this._scheduleReconnect();
|
|
82
|
+
reject(error);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// Connection timeout (10 seconds) - matching extension
|
|
86
|
+
const timeout = setTimeout(() => {
|
|
87
|
+
console.log('[WS-Client] Connection timeout (10s)');
|
|
88
|
+
if (this.ws) {
|
|
89
|
+
this.ws.close();
|
|
90
|
+
}
|
|
91
|
+
this.isConnecting = false;
|
|
92
|
+
this._scheduleReconnect();
|
|
93
|
+
reject(new Error('Connection timeout'));
|
|
94
|
+
}, 10000);
|
|
95
|
+
// WebSocket opened - send auth
|
|
96
|
+
this.ws.on('open', () => {
|
|
97
|
+
console.log('[WS-Client] ✓ WebSocket connection opened');
|
|
98
|
+
this.emit('connecting');
|
|
99
|
+
// Send authentication message - matching extension format
|
|
100
|
+
const authMessage = {
|
|
101
|
+
type: 'auth',
|
|
102
|
+
api_key: this.apiKey,
|
|
103
|
+
project_id: this.projectId,
|
|
104
|
+
};
|
|
105
|
+
console.log('[WS-Client] Sending authentication...');
|
|
106
|
+
this.ws.send(JSON.stringify(authMessage));
|
|
107
|
+
});
|
|
108
|
+
// Message received
|
|
109
|
+
this.ws.on('message', (data) => {
|
|
110
|
+
try {
|
|
111
|
+
const message = JSON.parse(data.toString());
|
|
112
|
+
if (message.type === 'auth_success') {
|
|
113
|
+
clearTimeout(timeout);
|
|
114
|
+
this.authenticated = true;
|
|
115
|
+
this.userInfo = message.user;
|
|
116
|
+
this.projectInfo = message.project;
|
|
117
|
+
this.isConnecting = false;
|
|
118
|
+
this.reconnectAttempts = 0;
|
|
119
|
+
console.log('[WS-Client] ✓✓✓ AUTHENTICATION SUCCESSFUL ✓✓✓');
|
|
120
|
+
if (this.userInfo) {
|
|
121
|
+
console.log(`[WS-Client] User: ${this.userInfo.email}`);
|
|
122
|
+
}
|
|
123
|
+
if (this.projectInfo) {
|
|
124
|
+
console.log(`[WS-Client] Project: ${this.projectInfo.project_name}`);
|
|
125
|
+
}
|
|
126
|
+
if (this.reconnectTimer) {
|
|
127
|
+
clearTimeout(this.reconnectTimer);
|
|
128
|
+
this.reconnectTimer = null;
|
|
129
|
+
}
|
|
130
|
+
// Emit connected event
|
|
131
|
+
this.emit('connected', this.projectInfo);
|
|
132
|
+
resolve(message);
|
|
133
|
+
}
|
|
134
|
+
else if (message.type === 'auth_failed') {
|
|
135
|
+
clearTimeout(timeout);
|
|
136
|
+
console.error('[WS-Client] ❌ AUTHENTICATION FAILED:', message.message);
|
|
137
|
+
this.isConnecting = false;
|
|
138
|
+
this.shouldReconnect = false; // Don't retry on auth failure
|
|
139
|
+
this.emit('auth_failed', message.message);
|
|
140
|
+
reject(new Error(message.message));
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
// Handle other messages
|
|
144
|
+
this.handleMessage(message);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
console.error('[WS-Client] Error parsing message:', err);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
// WebSocket error
|
|
152
|
+
this.ws.on('error', (error) => {
|
|
153
|
+
clearTimeout(timeout);
|
|
154
|
+
console.error('[WS-Client] WebSocket error:', error.message);
|
|
155
|
+
this.isConnecting = false;
|
|
156
|
+
this.emit('error', error);
|
|
157
|
+
this._scheduleReconnect();
|
|
158
|
+
reject(error);
|
|
159
|
+
});
|
|
160
|
+
// WebSocket closed
|
|
161
|
+
this.ws.on('close', (code, reason) => {
|
|
162
|
+
clearTimeout(timeout);
|
|
163
|
+
console.log(`[WS-Client] Connection closed (code: ${code}, reason: ${reason?.toString() || 'none'})`);
|
|
164
|
+
this.authenticated = false;
|
|
165
|
+
this.isConnecting = false;
|
|
166
|
+
this.emit('disconnected', code, reason?.toString());
|
|
167
|
+
if (this.shouldReconnect) {
|
|
168
|
+
this._scheduleReconnect();
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Schedule reconnection attempt
|
|
175
|
+
*/
|
|
176
|
+
_scheduleReconnect() {
|
|
177
|
+
if (this.reconnectTimer || !this.shouldReconnect) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
console.log(`[WS-Client] Scheduling reconnect in ${RECONNECT_INTERVAL}ms... (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
|
181
|
+
this.reconnectTimer = setTimeout(() => {
|
|
182
|
+
this.reconnectTimer = null;
|
|
183
|
+
console.log('[WS-Client] Attempting to reconnect...');
|
|
184
|
+
this._attemptConnection().catch(err => {
|
|
185
|
+
console.error('[WS-Client] Reconnection failed:', err.message);
|
|
186
|
+
});
|
|
187
|
+
}, RECONNECT_INTERVAL);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Handle incoming messages
|
|
191
|
+
*/
|
|
192
|
+
async handleMessage(message) {
|
|
193
|
+
console.log(`[WS-Client] ← Received: ${message.type}`);
|
|
194
|
+
if (message.type === 'execute_tool') {
|
|
195
|
+
await this.handleToolRequest(message);
|
|
196
|
+
}
|
|
197
|
+
else if (message.type === 'tool_ack') {
|
|
198
|
+
console.log(`[WS-Client] Server acknowledged tool: ${message.tool} (${message.request_id})`);
|
|
199
|
+
this.emit('tool_acknowledged', message);
|
|
200
|
+
}
|
|
201
|
+
else if (message.type === 'pong') {
|
|
202
|
+
console.log('[WS-Client] Pong received');
|
|
203
|
+
this.emit('pong');
|
|
204
|
+
}
|
|
205
|
+
else if (message.type === 'error') {
|
|
206
|
+
console.error('[WS-Client] Server error:', message.message);
|
|
207
|
+
this.emit('server_error', message.message);
|
|
208
|
+
}
|
|
209
|
+
else if (message.type === 'user_input_required') {
|
|
210
|
+
console.log('[WS-Client] 🔔 User input required notification received');
|
|
211
|
+
console.log(`[WS-Client] Agent: ${message.agent_name}`);
|
|
212
|
+
console.log(`[WS-Client] Questions: ${message.questions_count}`);
|
|
213
|
+
this.emit('user_input_required', {
|
|
214
|
+
agent_id: message.agent_id,
|
|
215
|
+
agent_name: message.agent_name,
|
|
216
|
+
questions_count: message.questions_count,
|
|
217
|
+
message: message.message
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
console.log('[WS-Client] Unknown message type:', message.type);
|
|
222
|
+
this.emit('message', message);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Handle tool execution request from server
|
|
227
|
+
*/
|
|
228
|
+
async handleToolRequest(message) {
|
|
229
|
+
const toolName = message.tool;
|
|
230
|
+
const params = message.params || {};
|
|
231
|
+
const requestId = message.request_id;
|
|
232
|
+
console.log(`[WS-Client] ═══════════════════════════════════`);
|
|
233
|
+
console.log(`[WS-Client] TOOL EXECUTION REQUEST`);
|
|
234
|
+
console.log(`[WS-Client] Tool: ${toolName}`);
|
|
235
|
+
console.log(`[WS-Client] Request ID: ${requestId}`);
|
|
236
|
+
console.log(`[WS-Client] Params:`, JSON.stringify(params, null, 2));
|
|
237
|
+
console.log(`[WS-Client] ═══════════════════════════════════`);
|
|
238
|
+
if (!this.toolsExecutor) {
|
|
239
|
+
console.error('[WS-Client] ❌ Tools executor not available');
|
|
240
|
+
await this.sendMessage({
|
|
241
|
+
type: 'tool_error',
|
|
242
|
+
tool: toolName,
|
|
243
|
+
request_id: requestId,
|
|
244
|
+
error: 'Tools executor not initialized'
|
|
245
|
+
});
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
console.log(`[WS-Client] Executing tool: ${toolName}...`);
|
|
250
|
+
const result = await this.toolsExecutor.executeTool(toolName, params);
|
|
251
|
+
console.log(`[WS-Client] ✓ Tool execution completed`);
|
|
252
|
+
console.log(`[WS-Client] Result status: ${result?.status || 'unknown'}`);
|
|
253
|
+
await this.sendMessage({
|
|
254
|
+
type: 'tool_result',
|
|
255
|
+
tool: toolName,
|
|
256
|
+
result: result,
|
|
257
|
+
request_id: requestId,
|
|
258
|
+
timestamp: new Date().toISOString()
|
|
259
|
+
});
|
|
260
|
+
console.log(`[WS-Client] → Tool result sent to server`);
|
|
261
|
+
this.emit('tool_executed', { tool: toolName, result, requestId });
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
console.error(`[WS-Client] ❌ Tool execution failed:`, error);
|
|
265
|
+
await this.sendMessage({
|
|
266
|
+
type: 'tool_error',
|
|
267
|
+
tool: toolName,
|
|
268
|
+
request_id: requestId,
|
|
269
|
+
error: error.message,
|
|
270
|
+
timestamp: new Date().toISOString()
|
|
271
|
+
});
|
|
272
|
+
this.emit('tool_error', { tool: toolName, error, requestId });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Send a message to the server
|
|
277
|
+
*/
|
|
278
|
+
async sendMessage(message) {
|
|
279
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
280
|
+
throw new Error('WebSocket not connected');
|
|
281
|
+
}
|
|
282
|
+
this.ws.send(JSON.stringify(message));
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Send a ping to keep connection alive
|
|
286
|
+
*/
|
|
287
|
+
async ping() {
|
|
288
|
+
if (this.isConnected()) {
|
|
289
|
+
await this.sendMessage({ type: 'ping' });
|
|
290
|
+
console.log('[WS-Client] → Sent: ping');
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Check if connected and authenticated
|
|
295
|
+
*/
|
|
296
|
+
isConnected() {
|
|
297
|
+
return this.authenticated && this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Disconnect from server
|
|
301
|
+
*/
|
|
302
|
+
disconnect() {
|
|
303
|
+
console.log('[WS-Client] Manual disconnect requested');
|
|
304
|
+
this.shouldReconnect = false;
|
|
305
|
+
if (this.reconnectTimer) {
|
|
306
|
+
clearTimeout(this.reconnectTimer);
|
|
307
|
+
this.reconnectTimer = null;
|
|
308
|
+
}
|
|
309
|
+
if (this.ws) {
|
|
310
|
+
this.ws.close();
|
|
311
|
+
this.ws = null;
|
|
312
|
+
}
|
|
313
|
+
this.authenticated = false;
|
|
314
|
+
this.emit('disconnected', 1000, 'Manual disconnect');
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Get project information
|
|
318
|
+
*/
|
|
319
|
+
getProjectInfo() {
|
|
320
|
+
return this.projectInfo;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Get user information
|
|
324
|
+
*/
|
|
325
|
+
getUserInfo() {
|
|
326
|
+
return this.userInfo;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Get current reconnection attempt count
|
|
330
|
+
*/
|
|
331
|
+
getReconnectAttempts() {
|
|
332
|
+
return this.reconnectAttempts;
|
|
333
|
+
}
|
|
334
|
+
}
|
package/build/index.js
CHANGED
|
@@ -1,132 +1,45 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
const program = new Command();
|
|
4
|
+
program
|
|
5
|
+
.name('shift')
|
|
6
|
+
.description('Shift CLI - AI-powered code intelligence')
|
|
7
|
+
.version('1.0.2');
|
|
8
|
+
// MCP server mode (default when run via MCP host)
|
|
9
|
+
program
|
|
10
|
+
.command('mcp', { isDefault: true, hidden: true })
|
|
11
|
+
.description('Start MCP server on stdio')
|
|
12
|
+
.action(async () => {
|
|
13
|
+
const { startMcpServer } = await import('./mcp-server.js');
|
|
14
|
+
await startMcpServer();
|
|
9
15
|
});
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return projectId.trim();
|
|
18
|
-
}
|
|
19
|
-
/** Resolve project_id: use tool arg if provided, else fall back to env. */
|
|
20
|
-
function resolveProjectId(args) {
|
|
21
|
-
const fromArgs = args.project_id?.trim();
|
|
22
|
-
if (fromArgs)
|
|
23
|
-
return fromArgs;
|
|
24
|
-
return getProjectIdFromEnv();
|
|
25
|
-
}
|
|
26
|
-
// helper
|
|
27
|
-
async function callBackendAPI(endpoint, data) {
|
|
28
|
-
try {
|
|
29
|
-
const response = await fetch(`${BASE_URL}${endpoint}`, {
|
|
30
|
-
method: 'POST',
|
|
31
|
-
headers: {
|
|
32
|
-
'Content-Type': 'application/json',
|
|
33
|
-
},
|
|
34
|
-
body: JSON.stringify(data),
|
|
35
|
-
});
|
|
36
|
-
if (!response.ok) {
|
|
37
|
-
const text = await response.text();
|
|
38
|
-
throw new Error(`API call failed: ${response.status} ${response.statusText}${text ? ` - ${text}` : ""}`);
|
|
39
|
-
}
|
|
40
|
-
return await response.json();
|
|
41
|
-
}
|
|
42
|
-
catch (error) {
|
|
43
|
-
console.error(`Error calling ${endpoint}:`, error);
|
|
44
|
-
throw error;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
// Tools:
|
|
48
|
-
// Blast radius
|
|
49
|
-
server.registerTool("blast_radius", {
|
|
50
|
-
description: "Analyzes the blast radius of a file or component - shows what would be affected if this file were modified or deleted",
|
|
51
|
-
inputSchema: z.object({
|
|
52
|
-
file_path: z.string().describe("Path to the file (relative to project root)"),
|
|
53
|
-
project_id: z.string().optional().describe("Shift Lite project UUID; overrides SHIFT_PROJECT_ID if provided"),
|
|
54
|
-
project_path: z.string().optional().describe("Path to the root of the project (optional)"),
|
|
55
|
-
level: z.number().optional().describe("Max depth of blast radius (optional)"),
|
|
56
|
-
})
|
|
57
|
-
}, async (args) => {
|
|
58
|
-
const projectId = resolveProjectId(args);
|
|
59
|
-
const data = await callBackendAPI('/api/v1/mcp/blast-radius', {
|
|
60
|
-
path: args.file_path,
|
|
61
|
-
project_id: projectId,
|
|
62
|
-
...(args.level != null && { level: args.level }),
|
|
63
|
-
});
|
|
64
|
-
return {
|
|
65
|
-
content: [
|
|
66
|
-
{
|
|
67
|
-
type: "text",
|
|
68
|
-
text: JSON.stringify(data, null, 2)
|
|
69
|
-
},
|
|
70
|
-
],
|
|
71
|
-
};
|
|
16
|
+
// CLI commands
|
|
17
|
+
program
|
|
18
|
+
.command('start')
|
|
19
|
+
.description('Start the Shift daemon for this project')
|
|
20
|
+
.action(async () => {
|
|
21
|
+
const { startCommand } = await import('./cli/commands/start.js');
|
|
22
|
+
await startCommand();
|
|
72
23
|
});
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
description
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
project_path: z.string().optional().describe("Path to the root of the project (optional)"),
|
|
80
|
-
})
|
|
81
|
-
}, async (args) => {
|
|
82
|
-
const projectId = resolveProjectId(args);
|
|
83
|
-
const data = await callBackendAPI('/api/v1/mcp/dependency', {
|
|
84
|
-
path: args.file_path,
|
|
85
|
-
project_id: projectId,
|
|
86
|
-
});
|
|
87
|
-
return {
|
|
88
|
-
content: [
|
|
89
|
-
{
|
|
90
|
-
type: "text",
|
|
91
|
-
text: JSON.stringify(data, null, 2)
|
|
92
|
-
},
|
|
93
|
-
],
|
|
94
|
-
};
|
|
24
|
+
program
|
|
25
|
+
.command('init')
|
|
26
|
+
.description('Initialize and scan the project for file indexing')
|
|
27
|
+
.action(async () => {
|
|
28
|
+
const { initCommand } = await import('./cli/commands/init.js');
|
|
29
|
+
await initCommand();
|
|
95
30
|
});
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
description
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
project_path: z.string().optional().describe("Path to the root of the project (optional)"),
|
|
103
|
-
level: z.number().optional().describe("Number of parent directory levels to include (default 0)"),
|
|
104
|
-
})
|
|
105
|
-
}, async (args) => {
|
|
106
|
-
const projectId = resolveProjectId(args);
|
|
107
|
-
const data = await callBackendAPI('/api/v1/mcp/what-is-this-file', {
|
|
108
|
-
path: args.file_path,
|
|
109
|
-
project_id: projectId,
|
|
110
|
-
level: args.level ?? 0,
|
|
111
|
-
});
|
|
112
|
-
return {
|
|
113
|
-
content: [
|
|
114
|
-
{
|
|
115
|
-
type: "text",
|
|
116
|
-
text: JSON.stringify(data, null, 2)
|
|
117
|
-
},
|
|
118
|
-
],
|
|
119
|
-
};
|
|
31
|
+
program
|
|
32
|
+
.command('stop')
|
|
33
|
+
.description('Stop the Shift daemon')
|
|
34
|
+
.action(async () => {
|
|
35
|
+
const { stopCommand } = await import('./cli/commands/stop.js');
|
|
36
|
+
await stopCommand();
|
|
120
37
|
});
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
main().catch((error) => {
|
|
130
|
-
console.error("Fatal error in main():", error);
|
|
131
|
-
process.exit(1);
|
|
38
|
+
program
|
|
39
|
+
.command('status')
|
|
40
|
+
.description('Show the current Shift status')
|
|
41
|
+
.action(async () => {
|
|
42
|
+
const { statusCommand } = await import('./cli/commands/status.js');
|
|
43
|
+
await statusCommand();
|
|
132
44
|
});
|
|
45
|
+
program.parse();
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
const BASE_URL = process.env.SHIFT_BACKEND_URL || "http://127.0.0.1:9000";
|
|
5
|
+
function getProjectIdFromEnv() {
|
|
6
|
+
const projectId = process.env.SHIFT_PROJECT_ID;
|
|
7
|
+
if (!projectId || projectId.trim() === "") {
|
|
8
|
+
throw new Error("SHIFT_PROJECT_ID environment variable is not set. " +
|
|
9
|
+
"Set it to your Shift Lite project UUID, or pass project_id in each tool call.");
|
|
10
|
+
}
|
|
11
|
+
return projectId.trim();
|
|
12
|
+
}
|
|
13
|
+
/** Resolve project_id: use tool arg if provided, else fall back to env. */
|
|
14
|
+
function resolveProjectId(args) {
|
|
15
|
+
const fromArgs = args.project_id?.trim();
|
|
16
|
+
if (fromArgs)
|
|
17
|
+
return fromArgs;
|
|
18
|
+
return getProjectIdFromEnv();
|
|
19
|
+
}
|
|
20
|
+
// helper
|
|
21
|
+
async function callBackendAPI(endpoint, data) {
|
|
22
|
+
try {
|
|
23
|
+
const response = await fetch(`${BASE_URL}${endpoint}`, {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: {
|
|
26
|
+
'Content-Type': 'application/json',
|
|
27
|
+
},
|
|
28
|
+
body: JSON.stringify(data),
|
|
29
|
+
});
|
|
30
|
+
if (!response.ok) {
|
|
31
|
+
const text = await response.text();
|
|
32
|
+
throw new Error(`API call failed: ${response.status} ${response.statusText}${text ? ` - ${text}` : ""}`);
|
|
33
|
+
}
|
|
34
|
+
return await response.json();
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
console.error(`Error calling ${endpoint}:`, error);
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export async function startMcpServer() {
|
|
42
|
+
// Create server instance
|
|
43
|
+
const server = new McpServer({
|
|
44
|
+
name: "shift",
|
|
45
|
+
version: "1.0.2",
|
|
46
|
+
});
|
|
47
|
+
// Tools:
|
|
48
|
+
// Blast radius
|
|
49
|
+
server.registerTool("blast_radius", {
|
|
50
|
+
description: "Analyzes the blast radius of a file or component - shows what would be affected if this file were modified or deleted",
|
|
51
|
+
inputSchema: z.object({
|
|
52
|
+
file_path: z.string().describe("Path to the file (relative to project root)"),
|
|
53
|
+
project_id: z.string().optional().describe("Shift Lite project UUID; overrides SHIFT_PROJECT_ID if provided"),
|
|
54
|
+
level: z.number().optional().describe("Max depth of blast radius (optional)"),
|
|
55
|
+
})
|
|
56
|
+
}, async (args) => {
|
|
57
|
+
const projectId = resolveProjectId(args);
|
|
58
|
+
const data = await callBackendAPI('/api/v1/mcp/blast-radius', {
|
|
59
|
+
path: args.file_path,
|
|
60
|
+
project_id: projectId,
|
|
61
|
+
...(args.level != null && { level: args.level }),
|
|
62
|
+
});
|
|
63
|
+
return {
|
|
64
|
+
content: [
|
|
65
|
+
{
|
|
66
|
+
type: "text",
|
|
67
|
+
text: JSON.stringify(data, null, 2)
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
// Dependencies
|
|
73
|
+
server.registerTool("dependencies", {
|
|
74
|
+
description: "Retrieves all dependencies for a given file or component, including direct and transitive dependencies",
|
|
75
|
+
inputSchema: z.object({
|
|
76
|
+
file_path: z.string().describe("Path to the file"),
|
|
77
|
+
project_id: z.string().optional().describe("Shift Lite project UUID; overrides SHIFT_PROJECT_ID if provided"),
|
|
78
|
+
})
|
|
79
|
+
}, async (args) => {
|
|
80
|
+
const projectId = resolveProjectId(args);
|
|
81
|
+
const data = await callBackendAPI('/api/v1/mcp/dependency', {
|
|
82
|
+
path: args.file_path,
|
|
83
|
+
project_id: projectId,
|
|
84
|
+
});
|
|
85
|
+
return {
|
|
86
|
+
content: [
|
|
87
|
+
{
|
|
88
|
+
type: "text",
|
|
89
|
+
text: JSON.stringify(data, null, 2)
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
// File summary (maps to what-is-this-file)
|
|
95
|
+
server.registerTool("file_summary", {
|
|
96
|
+
description: "Generates a comprehensive summary of a file including its purpose, exports, imports, and key functions, with optional parent directory context",
|
|
97
|
+
inputSchema: z.object({
|
|
98
|
+
file_path: z.string().describe("Path to the file to summarize"),
|
|
99
|
+
project_id: z.string().optional().describe("Shift Lite project UUID; overrides SHIFT_PROJECT_ID if provided"),
|
|
100
|
+
level: z.number().optional().describe("Number of parent directory levels to include (default 0)"),
|
|
101
|
+
})
|
|
102
|
+
}, async (args) => {
|
|
103
|
+
const projectId = resolveProjectId(args);
|
|
104
|
+
const data = await callBackendAPI('/api/v1/mcp/what-is-this-file', {
|
|
105
|
+
path: args.file_path,
|
|
106
|
+
project_id: projectId,
|
|
107
|
+
level: args.level ?? 0,
|
|
108
|
+
});
|
|
109
|
+
return {
|
|
110
|
+
content: [
|
|
111
|
+
{
|
|
112
|
+
type: "text",
|
|
113
|
+
text: JSON.stringify(data, null, 2)
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
const transport = new StdioServerTransport();
|
|
119
|
+
await server.connect(transport);
|
|
120
|
+
console.error("Shift MCP Server running on stdio");
|
|
121
|
+
if (!process.env.SHIFT_PROJECT_ID) {
|
|
122
|
+
console.error("Warning: SHIFT_PROJECT_ID is not set. Pass project_id in each tool call, or set the env var.");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { API_BASE_URL, API_BASE_URL_ORCH } from './config.js';
|
|
2
|
+
/**
|
|
3
|
+
* Fetch available projects for the user
|
|
4
|
+
* Matching extension's fetchProjects function in api-client.js
|
|
5
|
+
*/
|
|
6
|
+
export async function fetchProjects(apiKey) {
|
|
7
|
+
try {
|
|
8
|
+
const response = await fetch(`${API_BASE_URL}/api/vscode-projects`, {
|
|
9
|
+
method: 'GET',
|
|
10
|
+
headers: {
|
|
11
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
if (!response.ok) {
|
|
15
|
+
const text = await response.text();
|
|
16
|
+
throw new Error(text || `HTTP ${response.status}`);
|
|
17
|
+
}
|
|
18
|
+
const data = await response.json();
|
|
19
|
+
return data.projects || [];
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
console.error('Failed to fetch projects:', error);
|
|
23
|
+
throw new Error(error.message || 'Failed to fetch projects');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Send init scan to backend
|
|
28
|
+
* Matching extension's init-scan API call
|
|
29
|
+
*/
|
|
30
|
+
export async function sendInitScan(apiKey, projectId, payload) {
|
|
31
|
+
try {
|
|
32
|
+
const response = await fetch(`${API_BASE_URL_ORCH}/api/projects/${projectId}/init-scan`, {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: {
|
|
35
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
36
|
+
'Content-Type': 'application/json',
|
|
37
|
+
},
|
|
38
|
+
body: JSON.stringify(payload),
|
|
39
|
+
});
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
const text = await response.text();
|
|
42
|
+
throw new Error(text || `HTTP ${response.status}`);
|
|
43
|
+
}
|
|
44
|
+
return await response.json();
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
console.error('Failed to send init scan:', error);
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|