claudehq 1.0.2 → 1.0.5

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,421 @@
1
+ /**
2
+ * Projects Manager - Track and manage known project directories
3
+ *
4
+ * Provides autocomplete functionality for directory paths and
5
+ * tracks recently used directories for quick access.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const os = require('os');
11
+
12
+ const { validateDirectoryPath, normalizePath } = require('./path-validator');
13
+
14
+ // Default data directory
15
+ const DEFAULT_DATA_DIR = path.join(os.homedir(), '.claude', 'tasks-board');
16
+ const DEFAULT_PROJECTS_FILE = path.join(DEFAULT_DATA_DIR, 'projects.json');
17
+
18
+ // Maximum number of projects to track
19
+ const MAX_TRACKED_PROJECTS = 100;
20
+
21
+ // Maximum autocomplete results
22
+ const MAX_AUTOCOMPLETE_RESULTS = 15;
23
+
24
+ /**
25
+ * Create a projects manager instance
26
+ * @param {Object} options - Configuration options
27
+ * @param {string} options.dataDir - Directory for data storage
28
+ * @param {string} options.projectsFile - Path to projects JSON file
29
+ * @returns {Object} Projects manager instance
30
+ */
31
+ function createProjectsManager(options = {}) {
32
+ const dataDir = options.dataDir || DEFAULT_DATA_DIR;
33
+ const projectsFile = options.projectsFile || DEFAULT_PROJECTS_FILE;
34
+
35
+ // In-memory cache of projects
36
+ let projects = new Map();
37
+
38
+ /**
39
+ * Ensure data directory exists
40
+ */
41
+ function ensureDataDir() {
42
+ if (!fs.existsSync(dataDir)) {
43
+ fs.mkdirSync(dataDir, { recursive: true });
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Load projects from disk
49
+ */
50
+ function loadProjects() {
51
+ try {
52
+ if (fs.existsSync(projectsFile)) {
53
+ const content = fs.readFileSync(projectsFile, 'utf-8');
54
+ const data = JSON.parse(content);
55
+
56
+ projects.clear();
57
+ if (Array.isArray(data.projects)) {
58
+ for (const p of data.projects) {
59
+ projects.set(p.path, {
60
+ path: p.path,
61
+ name: p.name || path.basename(p.path),
62
+ lastUsed: p.lastUsed || Date.now(),
63
+ useCount: p.useCount || 1,
64
+ addedAt: p.addedAt || Date.now()
65
+ });
66
+ }
67
+ }
68
+ }
69
+ } catch (e) {
70
+ console.error('Error loading projects:', e.message);
71
+ projects.clear();
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Save projects to disk
77
+ */
78
+ function saveProjects() {
79
+ try {
80
+ ensureDataDir();
81
+
82
+ // Convert to array and sort by lastUsed
83
+ const projectsArray = Array.from(projects.values())
84
+ .sort((a, b) => b.lastUsed - a.lastUsed)
85
+ .slice(0, MAX_TRACKED_PROJECTS);
86
+
87
+ fs.writeFileSync(projectsFile, JSON.stringify({
88
+ version: 1,
89
+ updatedAt: new Date().toISOString(),
90
+ projects: projectsArray
91
+ }, null, 2));
92
+ } catch (e) {
93
+ console.error('Error saving projects:', e.message);
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Track a project directory
99
+ * @param {string} dirPath - Directory path to track
100
+ * @param {string} name - Optional display name
101
+ * @returns {Object} Result { success: boolean, project: object, error: string }
102
+ */
103
+ function trackProject(dirPath, name = null) {
104
+ const validation = validateDirectoryPath(dirPath);
105
+ if (!validation.valid) {
106
+ return { success: false, error: validation.error };
107
+ }
108
+
109
+ const normalizedPath = validation.path;
110
+ const existing = projects.get(normalizedPath);
111
+
112
+ if (existing) {
113
+ // Update existing project
114
+ existing.lastUsed = Date.now();
115
+ existing.useCount++;
116
+ if (name) {
117
+ existing.name = name;
118
+ }
119
+ saveProjects();
120
+ return { success: true, project: existing };
121
+ }
122
+
123
+ // Add new project
124
+ const project = {
125
+ path: normalizedPath,
126
+ name: name || path.basename(normalizedPath),
127
+ lastUsed: Date.now(),
128
+ useCount: 1,
129
+ addedAt: Date.now()
130
+ };
131
+
132
+ projects.set(normalizedPath, project);
133
+
134
+ // Trim if over limit
135
+ if (projects.size > MAX_TRACKED_PROJECTS) {
136
+ const sorted = Array.from(projects.entries())
137
+ .sort((a, b) => b[1].lastUsed - a[1].lastUsed);
138
+ projects = new Map(sorted.slice(0, MAX_TRACKED_PROJECTS));
139
+ }
140
+
141
+ saveProjects();
142
+ return { success: true, project };
143
+ }
144
+
145
+ /**
146
+ * Get a tracked project by path
147
+ * @param {string} dirPath - Directory path
148
+ * @returns {Object|null} Project or null
149
+ */
150
+ function getProject(dirPath) {
151
+ const normalized = normalizePath(dirPath);
152
+ return projects.get(normalized) || null;
153
+ }
154
+
155
+ /**
156
+ * List all tracked projects
157
+ * @param {Object} options - List options
158
+ * @param {number} options.limit - Max results (default: all)
159
+ * @param {string} options.sortBy - Sort field: 'lastUsed', 'useCount', 'name' (default: 'lastUsed')
160
+ * @returns {Array} Array of project objects
161
+ */
162
+ function listProjects(options = {}) {
163
+ const { limit, sortBy = 'lastUsed' } = options;
164
+
165
+ let result = Array.from(projects.values());
166
+
167
+ // Sort
168
+ switch (sortBy) {
169
+ case 'useCount':
170
+ result.sort((a, b) => b.useCount - a.useCount);
171
+ break;
172
+ case 'name':
173
+ result.sort((a, b) => a.name.localeCompare(b.name));
174
+ break;
175
+ case 'lastUsed':
176
+ default:
177
+ result.sort((a, b) => b.lastUsed - a.lastUsed);
178
+ }
179
+
180
+ if (limit && limit > 0) {
181
+ result = result.slice(0, limit);
182
+ }
183
+
184
+ return result;
185
+ }
186
+
187
+ /**
188
+ * Remove a tracked project
189
+ * @param {string} dirPath - Directory path
190
+ * @returns {Object} Result { success: boolean }
191
+ */
192
+ function removeProject(dirPath) {
193
+ const normalized = normalizePath(dirPath);
194
+ const existed = projects.delete(normalized);
195
+ if (existed) {
196
+ saveProjects();
197
+ }
198
+ return { success: existed };
199
+ }
200
+
201
+ /**
202
+ * Get filesystem directory completions
203
+ * @param {string} partial - Partial path to complete
204
+ * @returns {Array} Array of directory paths
205
+ */
206
+ function getFilesystemCompletions(partial) {
207
+ if (!partial) {
208
+ return [];
209
+ }
210
+
211
+ const results = [];
212
+ let searchDir;
213
+ let prefix = '';
214
+
215
+ const normalized = normalizePath(partial);
216
+
217
+ try {
218
+ const stats = fs.statSync(normalized);
219
+ if (stats.isDirectory()) {
220
+ // If the path is a complete directory, list its contents
221
+ searchDir = normalized;
222
+ prefix = '';
223
+ }
224
+ } catch (e) {
225
+ // Path doesn't exist as-is, look for completions
226
+ searchDir = path.dirname(normalized);
227
+ prefix = path.basename(normalized).toLowerCase();
228
+ }
229
+
230
+ if (!searchDir) {
231
+ return [];
232
+ }
233
+
234
+ try {
235
+ const entries = fs.readdirSync(searchDir, { withFileTypes: true });
236
+
237
+ for (const entry of entries) {
238
+ if (!entry.isDirectory()) continue;
239
+ if (entry.name.startsWith('.')) continue; // Skip hidden
240
+
241
+ if (!prefix || entry.name.toLowerCase().startsWith(prefix)) {
242
+ results.push({
243
+ path: path.join(searchDir, entry.name),
244
+ name: entry.name,
245
+ type: 'filesystem'
246
+ });
247
+ }
248
+ }
249
+
250
+ // Sort alphabetically
251
+ results.sort((a, b) => a.name.localeCompare(b.name));
252
+ } catch (e) {
253
+ // Ignore errors (permission denied, etc.)
254
+ }
255
+
256
+ return results.slice(0, MAX_AUTOCOMPLETE_RESULTS);
257
+ }
258
+
259
+ /**
260
+ * Fuzzy match projects by query
261
+ * @param {string} query - Search query
262
+ * @returns {Array} Matching projects
263
+ */
264
+ function fuzzyMatchProjects(query) {
265
+ if (!query) {
266
+ return listProjects({ limit: MAX_AUTOCOMPLETE_RESULTS });
267
+ }
268
+
269
+ const lowerQuery = query.toLowerCase();
270
+ const matches = [];
271
+
272
+ for (const project of projects.values()) {
273
+ const nameMatch = project.name.toLowerCase().includes(lowerQuery);
274
+ const pathMatch = project.path.toLowerCase().includes(lowerQuery);
275
+
276
+ if (nameMatch || pathMatch) {
277
+ matches.push({
278
+ ...project,
279
+ type: 'known',
280
+ score: nameMatch ? 2 : 1
281
+ });
282
+ }
283
+ }
284
+
285
+ // Sort by score then by lastUsed
286
+ matches.sort((a, b) => {
287
+ if (b.score !== a.score) return b.score - a.score;
288
+ return b.lastUsed - a.lastUsed;
289
+ });
290
+
291
+ return matches.slice(0, MAX_AUTOCOMPLETE_RESULTS);
292
+ }
293
+
294
+ /**
295
+ * Get autocomplete suggestions for a query
296
+ * @param {string} query - User query
297
+ * @param {Object} options - Options
298
+ * @param {number} options.limit - Max results (default: 15)
299
+ * @returns {Array} Array of suggestions
300
+ */
301
+ function autocomplete(query, options = {}) {
302
+ const { limit = MAX_AUTOCOMPLETE_RESULTS } = options;
303
+
304
+ if (!query || !query.trim()) {
305
+ // Return recent projects
306
+ return listProjects({ limit }).map(p => ({
307
+ ...p,
308
+ type: 'known'
309
+ }));
310
+ }
311
+
312
+ const trimmed = query.trim();
313
+ const results = [];
314
+
315
+ // Check if this looks like a path (starts with / or ~)
316
+ const isPathLike = trimmed.startsWith('/') || trimmed.startsWith('~');
317
+
318
+ if (isPathLike) {
319
+ // Get filesystem completions first
320
+ const fsResults = getFilesystemCompletions(trimmed);
321
+ results.push(...fsResults);
322
+ }
323
+
324
+ // Also get fuzzy matches from known projects
325
+ const knownMatches = fuzzyMatchProjects(trimmed);
326
+ for (const match of knownMatches) {
327
+ // Don't duplicate filesystem results
328
+ if (!results.find(r => r.path === match.path)) {
329
+ results.push(match);
330
+ }
331
+ }
332
+
333
+ // If query is a path-like but no fs results, still try fs completion
334
+ if (!isPathLike && results.length < limit) {
335
+ const fsResults = getFilesystemCompletions(trimmed);
336
+ for (const r of fsResults) {
337
+ if (!results.find(existing => existing.path === r.path)) {
338
+ results.push(r);
339
+ }
340
+ }
341
+ }
342
+
343
+ return results.slice(0, limit);
344
+ }
345
+
346
+ /**
347
+ * Get common parent directories from tracked projects
348
+ * @returns {Array} Array of common directories
349
+ */
350
+ function getCommonDirectories() {
351
+ const dirCounts = new Map();
352
+
353
+ for (const project of projects.values()) {
354
+ // Get parent directories up to 3 levels
355
+ let current = path.dirname(project.path);
356
+ for (let i = 0; i < 3 && current !== path.dirname(current); i++) {
357
+ dirCounts.set(current, (dirCounts.get(current) || 0) + 1);
358
+ current = path.dirname(current);
359
+ }
360
+ }
361
+
362
+ // Return directories with multiple projects
363
+ const common = [];
364
+ for (const [dir, count] of dirCounts) {
365
+ if (count >= 2) {
366
+ common.push({ path: dir, projectCount: count });
367
+ }
368
+ }
369
+
370
+ common.sort((a, b) => b.projectCount - a.projectCount);
371
+ return common.slice(0, 10);
372
+ }
373
+
374
+ /**
375
+ * Clear all tracked projects
376
+ */
377
+ function clearProjects() {
378
+ projects.clear();
379
+ saveProjects();
380
+ }
381
+
382
+ /**
383
+ * Initialize the manager
384
+ */
385
+ function init() {
386
+ loadProjects();
387
+ }
388
+
389
+ // Initialize on creation
390
+ init();
391
+
392
+ return {
393
+ // Project CRUD
394
+ trackProject,
395
+ getProject,
396
+ listProjects,
397
+ removeProject,
398
+ clearProjects,
399
+
400
+ // Autocomplete
401
+ autocomplete,
402
+ getFilesystemCompletions,
403
+ fuzzyMatchProjects,
404
+
405
+ // Analysis
406
+ getCommonDirectories,
407
+
408
+ // Lifecycle
409
+ init,
410
+ loadProjects,
411
+ saveProjects
412
+ };
413
+ }
414
+
415
+ module.exports = {
416
+ createProjectsManager,
417
+ DEFAULT_DATA_DIR,
418
+ DEFAULT_PROJECTS_FILE,
419
+ MAX_TRACKED_PROJECTS,
420
+ MAX_AUTOCOMPLETE_RESULTS
421
+ };