@latentforce/shift 1.0.7 → 1.0.9

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.
@@ -0,0 +1,175 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { exec } from 'child_process';
4
+ import { promisify } from 'util';
5
+ import { getApiKey, readProjectConfig } from '../../utils/config.js';
6
+ import { getProjectTree, extractAllFilePaths } from '../../utils/tree-scanner.js';
7
+ import { sendUpdateDrg } from '../../utils/api-client.js';
8
+ const execAsync = promisify(exec);
9
+ async function detectGitChanges(projectRoot, extensions) {
10
+ const changes = { added: [], modified: [], deleted: [] };
11
+ try {
12
+ const { stdout } = await execAsync('git status --porcelain', { cwd: projectRoot });
13
+ for (const line of stdout.split('\n')) {
14
+ if (!line.trim())
15
+ continue;
16
+ const statusCode = line.substring(0, 2);
17
+ let filePath = line.substring(3).trim();
18
+ // Handle renamed files (R status shows "old -> new")
19
+ if (filePath.includes(' -> ')) {
20
+ const parts = filePath.split(' -> ');
21
+ // Mark old path as deleted, new path as added
22
+ const oldPath = parts[0].trim();
23
+ const newPath = parts[1].trim();
24
+ const oldExt = path.extname(oldPath).toLowerCase();
25
+ const newExt = path.extname(newPath).toLowerCase();
26
+ if (extensions.has(oldExt))
27
+ changes.deleted.push(oldPath);
28
+ if (extensions.has(newExt))
29
+ changes.added.push(newPath);
30
+ continue;
31
+ }
32
+ // Filter by extensions
33
+ const ext = path.extname(filePath).toLowerCase();
34
+ if (!extensions.has(ext))
35
+ continue;
36
+ // Categorize based on git status codes
37
+ if (statusCode === '??' || statusCode.trimEnd() === 'A') {
38
+ changes.added.push(filePath);
39
+ }
40
+ else if (statusCode.includes('D')) {
41
+ changes.deleted.push(filePath);
42
+ }
43
+ else {
44
+ // M, MM, AM, etc. — treat as modified
45
+ changes.modified.push(filePath);
46
+ }
47
+ }
48
+ }
49
+ catch {
50
+ // Not a git repo or git not available
51
+ }
52
+ return changes;
53
+ }
54
+ const JS_TS_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
55
+ export async function updateDrgCommand(options = {}) {
56
+ const projectRoot = process.cwd();
57
+ const mode = options.mode || 'incremental';
58
+ console.log('╔═══════════════════════════════════════════════╗');
59
+ console.log('║ Updating Dependency Graph (DRG) ║');
60
+ console.log('╚═══════════════════════════════════════════════╝\n');
61
+ // Step 1: Check API key
62
+ console.log('[DRG] Step 1/4: Checking API key...');
63
+ const apiKey = getApiKey();
64
+ if (!apiKey) {
65
+ console.error('\n❌ No API key found. Run "shift-cli init" first to configure your API key.\n');
66
+ process.exit(1);
67
+ }
68
+ console.log('[DRG] ✓ API key found\n');
69
+ // Step 2: Read project config
70
+ console.log('[DRG] Step 2/4: Reading project configuration...');
71
+ const projectConfig = readProjectConfig(projectRoot);
72
+ if (!projectConfig) {
73
+ console.error('\n❌ No project configured for this directory.');
74
+ console.log('Run "shift-cli start" first to configure the project.\n');
75
+ process.exit(1);
76
+ }
77
+ console.log(`[DRG] ✓ Project: ${projectConfig.project_name} (${projectConfig.project_id})\n`);
78
+ // Step 3: Scan project for JS/TS files
79
+ console.log('[DRG] Step 3/4: Scanning project for JS/TS files...');
80
+ const treeData = getProjectTree(projectRoot, {
81
+ depth: 0,
82
+ exclude_patterns: [
83
+ '.git',
84
+ 'node_modules',
85
+ '__pycache__',
86
+ '.vscode',
87
+ 'dist',
88
+ 'build',
89
+ '.shift',
90
+ '.next',
91
+ 'coverage',
92
+ 'venv',
93
+ 'env',
94
+ ],
95
+ });
96
+ const allFiles = extractAllFilePaths(treeData.tree);
97
+ const jstsFiles = allFiles.filter((filePath) => {
98
+ const ext = path.extname(filePath).toLowerCase();
99
+ return JS_TS_EXTENSIONS.has(ext);
100
+ });
101
+ console.log(`[DRG] Total files scanned: ${allFiles.length}`);
102
+ console.log(`[DRG] JS/TS files found: ${jstsFiles.length}\n`);
103
+ if (jstsFiles.length === 0) {
104
+ console.log('⚠️ No JS/TS files found in project. Nothing to update.\n');
105
+ return;
106
+ }
107
+ // Read file contents
108
+ const files = [];
109
+ let readErrors = 0;
110
+ for (const filePath of jstsFiles) {
111
+ const absolutePath = path.join(projectRoot, filePath);
112
+ try {
113
+ const content = fs.readFileSync(absolutePath, 'utf-8');
114
+ files.push({ file_path: filePath, content });
115
+ }
116
+ catch {
117
+ readErrors++;
118
+ }
119
+ }
120
+ if (readErrors > 0) {
121
+ console.log(`[DRG] ⚠️ Could not read ${readErrors} file(s)\n`);
122
+ }
123
+ // Always use git to compute added/modified/deleted (single source of truth)
124
+ console.log('[DRG] Detecting git changes...');
125
+ const gitChanges = await detectGitChanges(projectRoot, JS_TS_EXTENSIONS);
126
+ console.log(`[DRG] Git changes — added: ${gitChanges.added.length}, modified: ${gitChanges.modified.length}, deleted: ${gitChanges.deleted.length}`);
127
+ // Which files to send: incremental = only changed; baseline = all (full re-analysis)
128
+ const changedPaths = new Set([...gitChanges.added, ...gitChanges.modified]);
129
+ const filesToSend = mode === 'baseline'
130
+ ? files
131
+ : files.filter((f) => changedPaths.has(f.file_path));
132
+ if (mode === 'baseline') {
133
+ console.log(`[DRG] Baseline: sending all ${filesToSend.length} JS/TS files.\n`);
134
+ }
135
+ else {
136
+ console.log(`[DRG] Incremental: sending ${filesToSend.length} changed file(s).\n`);
137
+ }
138
+ if (filesToSend.length === 0 && gitChanges.deleted.length === 0) {
139
+ console.log('\n✓ No JS/TS changes detected. DRG is up to date.\n');
140
+ return;
141
+ }
142
+ // Step 4: Send to backend
143
+ console.log(`[DRG] Step 4/4: Sending ${filesToSend.length} files to backend (mode: ${mode})...`);
144
+ const payload = {
145
+ project_id: projectConfig.project_id,
146
+ language: 'js_ts',
147
+ mode: mode,
148
+ changes: gitChanges,
149
+ files: filesToSend,
150
+ };
151
+ try {
152
+ const response = await sendUpdateDrg(apiKey, payload);
153
+ console.log('\n╔═══════════════════════════════════════════════╗');
154
+ console.log('║ ✓ DRG Update Complete ║');
155
+ console.log('╚═══════════════════════════════════════════════╝\n');
156
+ console.log(` Success: ${response.success}`);
157
+ console.log(` Message: ${response.message}`);
158
+ if (response.stats) {
159
+ console.log(` Files sent: ${response.stats.total_files_provided}`);
160
+ console.log(` Unique: ${response.stats.unique_paths}`);
161
+ console.log(` Added: ${response.stats.added}`);
162
+ console.log(` Modified: ${response.stats.modified}`);
163
+ console.log(` Deleted: ${response.stats.deleted}`);
164
+ const affected = response.stats.affected_file_paths;
165
+ if (affected?.length) {
166
+ console.log(` Affected by deletions (edges updated): ${affected.length} file(s)`);
167
+ }
168
+ }
169
+ console.log('\nUse "shift-cli status" to check progress.\n');
170
+ }
171
+ catch (error) {
172
+ console.error(`\n❌ Failed to update DRG: ${error.message}`);
173
+ process.exit(1);
174
+ }
175
+ }
@@ -6,6 +6,7 @@ import * as fs from 'fs/promises';
6
6
  import * as path from 'path';
7
7
  import { exec } from 'child_process';
8
8
  import { promisify } from 'util';
9
+ import { getProjectTree as getProjectTreeSync } from '../utils/tree-scanner.js';
9
10
  const execAsync = promisify(exec);
10
11
  export class ToolsExecutor {
11
12
  wsClient;
@@ -285,99 +286,31 @@ export class ToolsExecutor {
285
286
  };
286
287
  }
287
288
  /**
288
- * Get project tree
289
+ * Get project tree — delegates to shared tree-scanner utility
289
290
  */
290
291
  async getProjectTree(params) {
291
- const { depth = 0, exclude_patterns = [
292
- '.git',
293
- 'node_modules',
294
- '__pycache__',
295
- '.vscode',
296
- 'dist',
297
- 'build',
298
- '.shift',
299
- '.next',
300
- '.cache',
301
- 'coverage',
302
- '.pytest_cache',
303
- 'venv',
304
- 'env',
305
- '.env'
306
- ] } = params;
292
+ const { depth = 0, exclude_patterns } = params;
307
293
  console.log(`[ToolsExecutor] Getting project tree from workspace root: ${this.workspaceRoot}`);
308
294
  console.log(`[ToolsExecutor] Depth limit: ${depth === 0 ? 'unlimited' : depth}`);
309
- let file_count = 0;
310
- let dir_count = 0;
311
- let total_size = 0;
312
- const max_depth = depth === 0 ? Infinity : depth;
313
- const scanDirectory = async (dirPath, currentDepth = 0, relativePath = '') => {
314
- if (currentDepth >= max_depth) {
315
- return [];
316
- }
317
- const items = [];
318
- try {
319
- const entries = await fs.readdir(dirPath, { withFileTypes: true });
320
- for (const entry of entries) {
321
- if (exclude_patterns.some((pattern) => entry.name.includes(pattern))) {
322
- continue;
323
- }
324
- const itemPath = path.join(dirPath, entry.name);
325
- const itemRelativePath = relativePath ? path.join(relativePath, entry.name) : entry.name;
326
- if (entry.isDirectory()) {
327
- dir_count++;
328
- const children = await scanDirectory(itemPath, currentDepth + 1, itemRelativePath);
329
- items.push({
330
- name: entry.name,
331
- type: 'directory',
332
- path: itemRelativePath,
333
- depth: currentDepth,
334
- children: children
335
- });
336
- }
337
- else if (entry.isFile()) {
338
- file_count++;
339
- try {
340
- const stats = await fs.stat(itemPath);
341
- total_size += stats.size;
342
- const ext = path.extname(entry.name).toLowerCase();
343
- items.push({
344
- name: entry.name,
345
- type: 'file',
346
- path: itemRelativePath,
347
- depth: currentDepth,
348
- size: stats.size,
349
- extension: ext,
350
- modified: stats.mtime
351
- });
352
- }
353
- catch {
354
- console.warn(`[ToolsExecutor] Cannot read: ${itemPath}`);
355
- }
356
- }
357
- }
358
- }
359
- catch (err) {
360
- console.error(`[ToolsExecutor] Error scanning ${dirPath}:`, err.message);
361
- }
362
- return items;
363
- };
364
- const tree = await scanDirectory(this.workspaceRoot, 0, '');
365
- const total_size_mb = (total_size / (1024 * 1024)).toFixed(2);
295
+ const result = getProjectTreeSync(this.workspaceRoot, {
296
+ depth,
297
+ ...(exclude_patterns && { exclude_patterns }),
298
+ });
366
299
  console.log(`[ToolsExecutor] ✓ Scanned project tree:`);
367
- console.log(`[ToolsExecutor] Files: ${file_count}`);
368
- console.log(`[ToolsExecutor] Directories: ${dir_count}`);
369
- console.log(`[ToolsExecutor] Total size: ${total_size_mb} MB`);
300
+ console.log(`[ToolsExecutor] Files: ${result.file_count}`);
301
+ console.log(`[ToolsExecutor] Directories: ${result.dir_count}`);
302
+ console.log(`[ToolsExecutor] Total size: ${result.total_size_mb} MB`);
370
303
  return {
371
304
  status: 'success',
372
- tree: tree,
373
- file_count: file_count,
374
- dir_count: dir_count,
375
- total_size_bytes: total_size,
376
- total_size_mb: total_size_mb,
377
- scanned_from: this.workspaceRoot,
378
- depth_limit: depth,
379
- actual_max_depth: depth === 0 ? 'unlimited' : depth,
380
- excluded_patterns: exclude_patterns
305
+ tree: result.tree,
306
+ file_count: result.file_count,
307
+ dir_count: result.dir_count,
308
+ total_size_bytes: result.total_size_bytes,
309
+ total_size_mb: result.total_size_mb,
310
+ scanned_from: result.scanned_from,
311
+ depth_limit: result.depth_limit,
312
+ actual_max_depth: result.actual_max_depth,
313
+ excluded_patterns: result.excluded_patterns,
381
314
  };
382
315
  }
383
316
  }
package/build/index.js CHANGED
@@ -1,10 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
+ import { createRequire } from 'module';
4
+ const require = createRequire(import.meta.url);
5
+ const { version } = require('../package.json');
3
6
  const program = new Command();
4
7
  program
5
8
  .name('shift-cli')
6
9
  .description('Shift CLI - AI-powered code intelligence')
7
- .version('1.0.6');
10
+ .version(version);
8
11
  // MCP server mode (default when run via MCP host)
9
12
  program
10
13
  .command('mcp', { isDefault: true, hidden: true })
@@ -24,17 +27,40 @@ program
24
27
  program
25
28
  .command('start')
26
29
  .description('Start the Shift daemon for this project')
27
- .action(async () => {
30
+ .option('--guest', 'Use guest authentication (auto-creates project)')
31
+ .option('--api-key <key>', 'Provide API key directly')
32
+ .option('--project-name <name>', 'Create new project or match existing by name')
33
+ .option('--project-id <id>', 'Use existing project UUID')
34
+ .option('--template <id>', 'Migration template ID for project creation')
35
+ .action(async (options) => {
28
36
  const { startCommand } = await import('./cli/commands/start.js');
29
- await startCommand();
37
+ await startCommand({
38
+ guest: options.guest,
39
+ apiKey: options.apiKey,
40
+ projectName: options.projectName,
41
+ projectId: options.projectId,
42
+ template: options.template,
43
+ });
30
44
  });
31
45
  program
32
46
  .command('init')
33
47
  .description('Initialize and scan the project for file indexing')
34
48
  .option('-f, --force', 'Force re-indexing even if project is already indexed')
49
+ .option('--guest', 'Use guest authentication (auto-creates project)')
50
+ .option('--api-key <key>', 'Provide API key directly')
51
+ .option('--project-name <name>', 'Create new project or match existing by name')
52
+ .option('--project-id <id>', 'Use existing project UUID')
53
+ .option('--template <id>', 'Migration template ID for project creation')
35
54
  .action(async (options) => {
36
55
  const { initCommand } = await import('./cli/commands/init.js');
37
- await initCommand({ force: options.force ?? false });
56
+ await initCommand({
57
+ force: options.force ?? false,
58
+ guest: options.guest,
59
+ apiKey: options.apiKey,
60
+ projectName: options.projectName,
61
+ projectId: options.projectId,
62
+ template: options.template,
63
+ });
38
64
  });
39
65
  program
40
66
  .command('stop')
@@ -50,6 +76,14 @@ program
50
76
  const { statusCommand } = await import('./cli/commands/status.js');
51
77
  await statusCommand();
52
78
  });
79
+ program
80
+ .command('update-drg')
81
+ .description('Update the dependency relationship graph (DRG) for this project')
82
+ .option('-m, --mode <mode>', 'Update mode: baseline (all files) or incremental (git-changed only)', 'incremental')
83
+ .action(async (options) => {
84
+ const { updateDrgCommand } = await import('./cli/commands/update-drg.js');
85
+ await updateDrgCommand({ mode: options.mode });
86
+ });
53
87
  program
54
88
  .command('config [action] [key] [value]')
55
89
  .description('Manage Shift configuration (URLs, API key)')
@@ -1,6 +1,9 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
3
  import { z } from 'zod';
4
+ import { createRequire } from 'module';
5
+ const require = createRequire(import.meta.url);
6
+ const { version } = require('../package.json');
4
7
  const BASE_URL = process.env.SHIFT_BACKEND_URL || "https://dev-shift-lite.latentforce.ai";
5
8
  function getProjectIdFromEnv() {
6
9
  const projectId = process.env.SHIFT_PROJECT_ID;
@@ -17,6 +20,13 @@ function resolveProjectId(args) {
17
20
  return fromArgs;
18
21
  return getProjectIdFromEnv();
19
22
  }
23
+ /** Normalize file paths: backslash → forward slash, strip leading ./ and / */
24
+ function normalizePath(filePath) {
25
+ let p = filePath.replace(/\\/g, '/');
26
+ p = p.replace(/^\.\//, '');
27
+ p = p.replace(/^\//, '');
28
+ return p;
29
+ }
20
30
  // helper
21
31
  async function callBackendAPI(endpoint, data) {
22
32
  try {
@@ -29,20 +39,107 @@ async function callBackendAPI(endpoint, data) {
29
39
  });
30
40
  if (!response.ok) {
31
41
  const text = await response.text();
42
+ // Provide actionable error messages
43
+ if (response.status === 404) {
44
+ if (text.includes('Knowledge graph not found') || text.includes('knowledge graph')) {
45
+ throw new Error("No knowledge graph exists for this project. Run 'shift-cli update-drg' to build it.");
46
+ }
47
+ if (text.includes('File not found') || text.includes('not found in')) {
48
+ // Extract path from error if possible
49
+ const pathMatch = text.match(/['"]([^'"]+)['"]/);
50
+ const filePath = pathMatch ? pathMatch[1] : 'the requested file';
51
+ throw new Error(`File '${filePath}' not found in knowledge graph. Possible causes:\n` +
52
+ ` 1. The path may be incorrect (check casing and slashes)\n` +
53
+ ` 2. The file was added after the last 'shift-cli update-drg'\n` +
54
+ ` 3. The file type is not supported (only JS/TS files are indexed)`);
55
+ }
56
+ }
32
57
  throw new Error(`API call failed: ${response.status} ${response.statusText}${text ? ` - ${text}` : ""}`);
33
58
  }
34
59
  return await response.json();
35
60
  }
36
61
  catch (error) {
37
- console.error(`Error calling ${endpoint}:`, error);
38
62
  throw error;
39
63
  }
40
64
  }
65
+ /** Format file_summary response as readable markdown */
66
+ function formatFileSummary(data) {
67
+ const lines = [];
68
+ lines.push(`## File: ${data.path}`);
69
+ lines.push('');
70
+ lines.push('### Summary');
71
+ lines.push(data.summary || 'No summary available.');
72
+ if (data.parent_summaries?.length > 0) {
73
+ lines.push('');
74
+ lines.push('### Directory Context');
75
+ for (const parent of data.parent_summaries) {
76
+ lines.push(`- **${parent.path}**: ${parent.summary}`);
77
+ }
78
+ }
79
+ return lines.join('\n');
80
+ }
81
+ /** Format dependency response as readable markdown */
82
+ function formatDependencies(data) {
83
+ const lines = [];
84
+ const deps = data.dependencies || [];
85
+ const edges = data.edge_summaries || {};
86
+ lines.push(`## Dependencies: ${data.path}`);
87
+ lines.push('');
88
+ if (deps.length === 0) {
89
+ lines.push('This file has no dependencies.');
90
+ }
91
+ else {
92
+ lines.push(`This file depends on **${deps.length}** file(s):`);
93
+ lines.push('');
94
+ for (const dep of deps) {
95
+ const summary = edges[dep];
96
+ if (summary) {
97
+ lines.push(`- **${dep}**: ${summary}`);
98
+ }
99
+ else {
100
+ lines.push(`- **${dep}**`);
101
+ }
102
+ }
103
+ }
104
+ return lines.join('\n');
105
+ }
106
+ /** Format blast_radius response as readable markdown */
107
+ function formatBlastRadius(data) {
108
+ const lines = [];
109
+ const affected = data.affected_files || [];
110
+ lines.push(`## Blast Radius: ${data.path}`);
111
+ lines.push('');
112
+ if (affected.length === 0) {
113
+ lines.push('No other files are affected by changes to this file.');
114
+ }
115
+ else {
116
+ lines.push(`**${affected.length}** file(s) would be affected:`);
117
+ lines.push('');
118
+ // Group by level
119
+ const byLevel = {};
120
+ for (const file of affected) {
121
+ const level = file.level || 1;
122
+ if (!byLevel[level])
123
+ byLevel[level] = [];
124
+ byLevel[level].push(file);
125
+ }
126
+ const levels = Object.keys(byLevel).map(Number).sort((a, b) => a - b);
127
+ for (const level of levels) {
128
+ const label = level === 1 ? 'Level 1 (direct)' : `Level ${level}`;
129
+ lines.push(`### ${label}`);
130
+ for (const file of byLevel[level]) {
131
+ lines.push(`- **${file.path}**: ${file.summary || 'No summary'}`);
132
+ }
133
+ lines.push('');
134
+ }
135
+ }
136
+ return lines.join('\n');
137
+ }
41
138
  export async function startMcpServer() {
42
139
  // Create server instance
43
140
  const server = new McpServer({
44
141
  name: "shift",
45
- version: "1.0.2",
142
+ version,
46
143
  });
47
144
  // Tools:
48
145
  // Blast radius
@@ -56,7 +153,7 @@ export async function startMcpServer() {
56
153
  }, async (args) => {
57
154
  const projectId = resolveProjectId(args);
58
155
  const data = await callBackendAPI('/api/v1/mcp/blast-radius', {
59
- path: args.file_path,
156
+ path: normalizePath(args.file_path),
60
157
  project_id: projectId,
61
158
  ...(args.level != null && { level: args.level }),
62
159
  });
@@ -64,7 +161,7 @@ export async function startMcpServer() {
64
161
  content: [
65
162
  {
66
163
  type: "text",
67
- text: JSON.stringify(data, null, 2)
164
+ text: formatBlastRadius(data)
68
165
  },
69
166
  ],
70
167
  };
@@ -79,14 +176,14 @@ export async function startMcpServer() {
79
176
  }, async (args) => {
80
177
  const projectId = resolveProjectId(args);
81
178
  const data = await callBackendAPI('/api/v1/mcp/dependency', {
82
- path: args.file_path,
179
+ path: normalizePath(args.file_path),
83
180
  project_id: projectId,
84
181
  });
85
182
  return {
86
183
  content: [
87
184
  {
88
185
  type: "text",
89
- text: JSON.stringify(data, null, 2)
186
+ text: formatDependencies(data)
90
187
  },
91
188
  ],
92
189
  };
@@ -102,7 +199,7 @@ export async function startMcpServer() {
102
199
  }, async (args) => {
103
200
  const projectId = resolveProjectId(args);
104
201
  const data = await callBackendAPI('/api/v1/mcp/what-is-this-file', {
105
- path: args.file_path,
202
+ path: normalizePath(args.file_path),
106
203
  project_id: projectId,
107
204
  level: args.level ?? 0,
108
205
  });
@@ -110,7 +207,7 @@ export async function startMcpServer() {
110
207
  content: [
111
208
  {
112
209
  type: "text",
113
- text: JSON.stringify(data, null, 2)
210
+ text: formatFileSummary(data)
114
211
  },
115
212
  ],
116
213
  };