prjct-cli 0.44.1 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,458 @@
1
+ /**
2
+ * Context Tools - Smart context filtering for AI agents
3
+ *
4
+ * Provides terminal tools that Claude can use to explore codebases
5
+ * efficiently WITHOUT consuming tokens for filtering.
6
+ *
7
+ * Tools:
8
+ * - files: Find relevant files for a task
9
+ * - signatures: Extract code structure without implementation
10
+ * - imports: Build dependency graphs
11
+ * - recent: Find hot files from git history
12
+ * - summary: Intelligent file summarization
13
+ *
14
+ * @module context-tools
15
+ * @version 1.0.0
16
+ */
17
+
18
+ import type { ContextToolOutput, ContextToolUsage } from './types'
19
+ import { findRelevantFiles } from './files-tool'
20
+ import { extractSignatures, extractDirectorySignatures } from './signatures-tool'
21
+ import { analyzeImports } from './imports-tool'
22
+ import { getRecentFiles } from './recent-tool'
23
+ import { summarizeFile, summarizeDirectory } from './summary-tool'
24
+ import { combineMetrics } from './token-counter'
25
+ import { metricsStorage } from '../storage/metrics-storage'
26
+ import configManager from '../infrastructure/config-manager'
27
+ import { getTimestamp } from '../utils/date-helper'
28
+
29
+ // =============================================================================
30
+ // CLI Dispatcher
31
+ // =============================================================================
32
+
33
+ /**
34
+ * Run a context tool from CLI arguments
35
+ *
36
+ * Usage:
37
+ * prjct context files "add authentication"
38
+ * prjct context signatures core/auth/service.ts
39
+ * prjct context imports core/auth/service.ts --reverse
40
+ * prjct context recent 50
41
+ * prjct context recent --branch
42
+ * prjct context summary core/auth/service.ts
43
+ *
44
+ * @param args - CLI arguments (tool name + args)
45
+ * @param projectId - Project ID for metrics tracking
46
+ * @param projectPath - Project root path
47
+ * @returns JSON output for Claude
48
+ */
49
+ export async function runContextTool(
50
+ args: string[],
51
+ projectId: string,
52
+ projectPath: string
53
+ ): Promise<ContextToolOutput> {
54
+ const startTime = Date.now()
55
+ const [toolName, ...toolArgs] = args
56
+
57
+ try {
58
+ let result: ContextToolOutput
59
+
60
+ switch (toolName) {
61
+ case 'files':
62
+ result = await runFilesTool(toolArgs, projectPath)
63
+ break
64
+
65
+ case 'signatures':
66
+ result = await runSignaturesTool(toolArgs, projectPath)
67
+ break
68
+
69
+ case 'imports':
70
+ result = await runImportsTool(toolArgs, projectPath)
71
+ break
72
+
73
+ case 'recent':
74
+ result = await runRecentTool(toolArgs, projectPath)
75
+ break
76
+
77
+ case 'summary':
78
+ result = await runSummaryTool(toolArgs, projectPath)
79
+ break
80
+
81
+ case 'help':
82
+ return {
83
+ tool: 'error',
84
+ result: {
85
+ error: getHelpText(),
86
+ code: 'HELP',
87
+ },
88
+ }
89
+
90
+ default:
91
+ return {
92
+ tool: 'error',
93
+ result: {
94
+ error: `Unknown tool: ${toolName}. Use 'prjct context help' for usage.`,
95
+ code: 'UNKNOWN_TOOL',
96
+ },
97
+ }
98
+ }
99
+
100
+ // Record usage metrics
101
+ const duration = Date.now() - startTime
102
+ const tokensSaved = getTokensSaved(result)
103
+ const compressionRate = getCompressionRate(result)
104
+
105
+ await recordToolUsage(projectId, {
106
+ tool: toolName as ContextToolUsage['tool'],
107
+ timestamp: getTimestamp(),
108
+ inputArgs: toolArgs.join(' '),
109
+ tokensSaved,
110
+ compressionRate,
111
+ duration,
112
+ })
113
+
114
+ return result
115
+ } catch (error) {
116
+ return {
117
+ tool: 'error',
118
+ result: {
119
+ error: (error as Error).message,
120
+ code: 'EXECUTION_ERROR',
121
+ },
122
+ }
123
+ }
124
+ }
125
+
126
+ // =============================================================================
127
+ // Tool Runners
128
+ // =============================================================================
129
+
130
+ async function runFilesTool(
131
+ args: string[],
132
+ projectPath: string
133
+ ): Promise<ContextToolOutput> {
134
+ // Parse options
135
+ const options: { maxFiles?: number; minScore?: number; includeTests?: boolean } = {}
136
+ const taskParts: string[] = []
137
+
138
+ for (let i = 0; i < args.length; i++) {
139
+ if (args[i] === '--max' && args[i + 1]) {
140
+ options.maxFiles = parseInt(args[++i])
141
+ } else if (args[i] === '--min-score' && args[i + 1]) {
142
+ options.minScore = parseFloat(args[++i])
143
+ } else if (args[i] === '--include-tests') {
144
+ options.includeTests = true
145
+ } else {
146
+ taskParts.push(args[i])
147
+ }
148
+ }
149
+
150
+ const taskDescription = taskParts.join(' ')
151
+ if (!taskDescription) {
152
+ return {
153
+ tool: 'error',
154
+ result: {
155
+ error: 'Usage: prjct context files "<task description>"',
156
+ code: 'MISSING_ARG',
157
+ },
158
+ }
159
+ }
160
+
161
+ const result = await findRelevantFiles(taskDescription, projectPath, options)
162
+ return { tool: 'files', result }
163
+ }
164
+
165
+ async function runSignaturesTool(
166
+ args: string[],
167
+ projectPath: string
168
+ ): Promise<ContextToolOutput> {
169
+ const filePath = args[0]
170
+ if (!filePath) {
171
+ return {
172
+ tool: 'error',
173
+ result: {
174
+ error: 'Usage: prjct context signatures <file_or_directory>',
175
+ code: 'MISSING_ARG',
176
+ },
177
+ }
178
+ }
179
+
180
+ // Check if it's a directory
181
+ const fs = await import('fs/promises')
182
+ const path = await import('path')
183
+ const fullPath = path.isAbsolute(filePath)
184
+ ? filePath
185
+ : path.join(projectPath, filePath)
186
+
187
+ try {
188
+ const stat = await fs.stat(fullPath)
189
+ if (stat.isDirectory()) {
190
+ // Return multiple results
191
+ const results = await extractDirectorySignatures(filePath, projectPath, {
192
+ recursive: args.includes('--recursive') || args.includes('-r'),
193
+ })
194
+ // Combine into single output with cost savings
195
+ const combinedMetrics = combineMetrics(results.map(r => r.metrics))
196
+ const combined = {
197
+ file: filePath,
198
+ language: 'multiple',
199
+ signatures: results.flatMap((r) =>
200
+ r.signatures.map((s) => ({ ...s, file: r.file }))
201
+ ),
202
+ fallback: false,
203
+ metrics: combinedMetrics,
204
+ }
205
+ return { tool: 'signatures', result: combined }
206
+ }
207
+ } catch {
208
+ // Not a directory, try as file
209
+ }
210
+
211
+ const result = await extractSignatures(filePath, projectPath)
212
+ return { tool: 'signatures', result }
213
+ }
214
+
215
+ async function runImportsTool(
216
+ args: string[],
217
+ projectPath: string
218
+ ): Promise<ContextToolOutput> {
219
+ const filePath = args[0]
220
+ if (!filePath) {
221
+ return {
222
+ tool: 'error',
223
+ result: {
224
+ error: 'Usage: prjct context imports <file> [--reverse] [--depth N]',
225
+ code: 'MISSING_ARG',
226
+ },
227
+ }
228
+ }
229
+
230
+ // Parse options
231
+ const options: { reverse?: boolean; depth?: number } = {}
232
+ for (let i = 1; i < args.length; i++) {
233
+ if (args[i] === '--reverse' || args[i] === '-r') {
234
+ options.reverse = true
235
+ } else if ((args[i] === '--depth' || args[i] === '-d') && args[i + 1]) {
236
+ options.depth = parseInt(args[++i])
237
+ }
238
+ }
239
+
240
+ const result = await analyzeImports(filePath, projectPath, options)
241
+ return { tool: 'imports', result }
242
+ }
243
+
244
+ async function runRecentTool(
245
+ args: string[],
246
+ projectPath: string
247
+ ): Promise<ContextToolOutput> {
248
+ const options: { commits?: number; branch?: boolean; maxFiles?: number } = {}
249
+
250
+ for (let i = 0; i < args.length; i++) {
251
+ if (args[i] === '--branch' || args[i] === '-b') {
252
+ options.branch = true
253
+ } else if (args[i] === '--max' && args[i + 1]) {
254
+ options.maxFiles = parseInt(args[++i])
255
+ } else if (/^\d+$/.test(args[i])) {
256
+ options.commits = parseInt(args[i])
257
+ }
258
+ }
259
+
260
+ const result = await getRecentFiles(projectPath, options)
261
+ return { tool: 'recent', result }
262
+ }
263
+
264
+ async function runSummaryTool(
265
+ args: string[],
266
+ projectPath: string
267
+ ): Promise<ContextToolOutput> {
268
+ const targetPath = args[0]
269
+ if (!targetPath) {
270
+ return {
271
+ tool: 'error',
272
+ result: {
273
+ error: 'Usage: prjct context summary <file_or_directory> [--recursive]',
274
+ code: 'MISSING_ARG',
275
+ },
276
+ }
277
+ }
278
+
279
+ // Check if it's a directory
280
+ const fs = await import('fs/promises')
281
+ const path = await import('path')
282
+ const fullPath = path.isAbsolute(targetPath)
283
+ ? targetPath
284
+ : path.join(projectPath, targetPath)
285
+
286
+ try {
287
+ const stat = await fs.stat(fullPath)
288
+ if (stat.isDirectory()) {
289
+ const results = await summarizeDirectory(targetPath, projectPath, {
290
+ recursive: args.includes('--recursive') || args.includes('-r'),
291
+ })
292
+ // Combine into aggregate output
293
+ const combined = {
294
+ file: targetPath,
295
+ purpose: `Directory with ${results.length} files`,
296
+ publicAPI: results.flatMap((r) =>
297
+ r.publicAPI.map((api) => ({ ...api, file: r.file }))
298
+ ),
299
+ dependencies: [...new Set(results.flatMap((r) => r.dependencies))],
300
+ metrics: combineMetrics(results.map(r => r.metrics)),
301
+ }
302
+ return { tool: 'summary', result: combined }
303
+ }
304
+ } catch {
305
+ // Not a directory, try as file
306
+ }
307
+
308
+ const result = await summarizeFile(targetPath, projectPath)
309
+ return { tool: 'summary', result }
310
+ }
311
+
312
+ // =============================================================================
313
+ // Metrics Helpers
314
+ // =============================================================================
315
+
316
+ function getTokensSaved(result: ContextToolOutput): number {
317
+ if (result.tool === 'error') return 0
318
+
319
+ switch (result.tool) {
320
+ case 'signatures':
321
+ case 'summary':
322
+ return result.result.metrics.tokens.saved
323
+ case 'files':
324
+ // Estimate tokens saved by returning fewer files
325
+ return result.result.metrics.filesScanned * 50 - result.result.metrics.filesReturned * 50
326
+ case 'imports':
327
+ // Estimate based on not needing to read full files
328
+ return result.result.metrics.totalImports * 20
329
+ case 'recent':
330
+ // Estimate based on focused file list
331
+ return result.result.metrics.totalFilesChanged * 30
332
+ default:
333
+ return 0
334
+ }
335
+ }
336
+
337
+ function getCompressionRate(result: ContextToolOutput): number {
338
+ if (result.tool === 'error') return 0
339
+
340
+ switch (result.tool) {
341
+ case 'signatures':
342
+ case 'summary':
343
+ return result.result.metrics.compression
344
+ case 'files':
345
+ const scanned = result.result.metrics.filesScanned
346
+ const returned = result.result.metrics.filesReturned
347
+ return scanned > 0 ? (scanned - returned) / scanned : 0
348
+ default:
349
+ return 0
350
+ }
351
+ }
352
+
353
+ async function recordToolUsage(
354
+ projectId: string,
355
+ usage: ContextToolUsage
356
+ ): Promise<void> {
357
+ try {
358
+ // Record to metrics storage
359
+ await metricsStorage.recordSync(projectId, {
360
+ originalSize: usage.tokensSaved + 100, // Estimate original
361
+ filteredSize: 100, // Estimate filtered
362
+ duration: usage.duration,
363
+ isWatch: false,
364
+ agents: [`context-${usage.tool}`],
365
+ })
366
+ } catch {
367
+ // Metrics recording failure is non-fatal
368
+ }
369
+ }
370
+
371
+ // =============================================================================
372
+ // Help Text
373
+ // =============================================================================
374
+
375
+ function getHelpText(): string {
376
+ return `
377
+ Context Tools - Smart context filtering for AI agents
378
+
379
+ USAGE:
380
+ prjct context <tool> [args] [options]
381
+
382
+ TOOLS:
383
+
384
+ files <task>
385
+ Find files relevant to a task description.
386
+ Options:
387
+ --max N Maximum files to return (default: 30)
388
+ --min-score N Minimum relevance score 0-1 (default: 0.1)
389
+ --include-tests Include test files
390
+
391
+ Example:
392
+ prjct context files "add user authentication"
393
+
394
+ signatures <path>
395
+ Extract code structure without implementation (~90% compression).
396
+ Path can be a file or directory.
397
+ Options:
398
+ --recursive, -r Process directories recursively
399
+
400
+ Example:
401
+ prjct context signatures core/auth/service.ts
402
+ prjct context signatures core/auth/ --recursive
403
+
404
+ imports <file>
405
+ Analyze import/dependency relationships.
406
+ Options:
407
+ --reverse, -r Show files that import this file
408
+ --depth N, -d N Build dependency tree to depth N
409
+
410
+ Example:
411
+ prjct context imports core/auth/service.ts --reverse
412
+ prjct context imports core/auth/service.ts --depth 2
413
+
414
+ recent [commits]
415
+ Find recently modified "hot" files.
416
+ Options:
417
+ --branch, -b Only files changed in current branch vs main
418
+ --max N Maximum files to return (default: 50)
419
+
420
+ Example:
421
+ prjct context recent
422
+ prjct context recent 50
423
+ prjct context recent --branch
424
+
425
+ summary <path>
426
+ Generate intelligent file summary (public API + docs).
427
+ Options:
428
+ --recursive, -r Process directories recursively
429
+
430
+ Example:
431
+ prjct context summary core/auth/service.ts
432
+ prjct context summary core/services/ --recursive
433
+
434
+ OUTPUT:
435
+ All tools output JSON for easy parsing by AI agents.
436
+ Each output includes metrics showing token savings.
437
+ `.trim()
438
+ }
439
+
440
+ // =============================================================================
441
+ // Exports
442
+ // =============================================================================
443
+
444
+ export {
445
+ // Individual tools for programmatic use
446
+ findRelevantFiles,
447
+ extractSignatures,
448
+ extractDirectorySignatures,
449
+ analyzeImports,
450
+ getRecentFiles,
451
+ summarizeFile,
452
+ summarizeDirectory,
453
+ }
454
+ // Note: runContextTool is already exported at its definition (line 48)
455
+
456
+ // Re-export types
457
+ export * from './types'
458
+ export * from './token-counter'