bluera-knowledge 0.9.43 → 0.10.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.
Files changed (43) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +75 -0
  3. package/README.md +114 -42
  4. package/commands/sync.md +96 -0
  5. package/dist/{chunk-MQE32YY6.js → chunk-6U45VP5Z.js} +42 -6
  6. package/dist/chunk-6U45VP5Z.js.map +1 -0
  7. package/dist/{chunk-CUHYSPRV.js → chunk-DP5XBPQV.js} +372 -2
  8. package/dist/chunk-DP5XBPQV.js.map +1 -0
  9. package/dist/{chunk-DWAIT2OD.js → chunk-UE4ZIJYA.js} +74 -5
  10. package/dist/{chunk-DWAIT2OD.js.map → chunk-UE4ZIJYA.js.map} +1 -1
  11. package/dist/index.js +216 -7
  12. package/dist/index.js.map +1 -1
  13. package/dist/mcp/server.js +2 -2
  14. package/dist/workers/background-worker-cli.js +4 -3
  15. package/dist/workers/background-worker-cli.js.map +1 -1
  16. package/hooks/check-dependencies.sh +29 -0
  17. package/package.json +1 -1
  18. package/python/crawl_worker.py +6 -1
  19. package/src/cli/commands/crawl.test.ts +43 -3
  20. package/src/cli/commands/crawl.ts +3 -3
  21. package/src/cli/commands/sync.test.ts +54 -0
  22. package/src/cli/commands/sync.ts +264 -0
  23. package/src/cli/index.ts +1 -0
  24. package/src/crawl/claude-client.test.ts +195 -24
  25. package/src/crawl/claude-client.ts +38 -3
  26. package/src/crawl/intelligent-crawler.test.ts +65 -0
  27. package/src/crawl/intelligent-crawler.ts +14 -2
  28. package/src/index.ts +2 -0
  29. package/src/mcp/commands/index.ts +2 -0
  30. package/src/mcp/commands/sync.commands.test.ts +283 -0
  31. package/src/mcp/commands/sync.commands.ts +233 -0
  32. package/src/services/gitignore.service.test.ts +157 -0
  33. package/src/services/gitignore.service.ts +132 -0
  34. package/src/services/store-definition.service.test.ts +440 -0
  35. package/src/services/store-definition.service.ts +198 -0
  36. package/src/services/store.service.test.ts +279 -1
  37. package/src/services/store.service.ts +101 -4
  38. package/src/types/index.ts +18 -0
  39. package/src/types/store-definition.test.ts +492 -0
  40. package/src/types/store-definition.ts +129 -0
  41. package/src/workers/background-worker.ts +1 -1
  42. package/dist/chunk-CUHYSPRV.js.map +0 -1
  43. package/dist/chunk-MQE32YY6.js.map +0 -1
@@ -0,0 +1,132 @@
1
+ import { readFile, writeFile, access } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * Required .gitignore patterns for Bluera Knowledge
6
+ *
7
+ * These patterns ensure:
8
+ * - The .bluera/ data directory (vector DB, cloned repos) is ignored
9
+ * - The stores.config.json file is NOT ignored (committed for team sharing)
10
+ */
11
+ const REQUIRED_PATTERNS = [
12
+ '.bluera/',
13
+ '!.bluera/bluera-knowledge/',
14
+ '!.bluera/bluera-knowledge/stores.config.json',
15
+ ];
16
+
17
+ /**
18
+ * Header comment for the gitignore section
19
+ */
20
+ const SECTION_HEADER = `
21
+ # Bluera Knowledge - data directory (not committed)
22
+ # Store definitions at .bluera/bluera-knowledge/stores.config.json ARE committed for team sharing
23
+ `;
24
+
25
+ /**
26
+ * Check if a file exists
27
+ */
28
+ async function fileExists(path: string): Promise<boolean> {
29
+ try {
30
+ await access(path);
31
+ return true;
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Service for managing .gitignore patterns for Bluera Knowledge.
39
+ *
40
+ * When stores are created, this service ensures the project's .gitignore
41
+ * is updated to:
42
+ * - Ignore the .bluera/ data directory (not committed)
43
+ * - Allow committing .bluera/bluera-knowledge/stores.config.json (for team sharing)
44
+ */
45
+ export class GitignoreService {
46
+ private readonly gitignorePath: string;
47
+
48
+ constructor(projectRoot: string) {
49
+ this.gitignorePath = join(projectRoot, '.gitignore');
50
+ }
51
+
52
+ /**
53
+ * Check if all required patterns are present in .gitignore
54
+ */
55
+ async hasRequiredPatterns(): Promise<boolean> {
56
+ const exists = await fileExists(this.gitignorePath);
57
+ if (!exists) {
58
+ return false;
59
+ }
60
+
61
+ const content = await readFile(this.gitignorePath, 'utf-8');
62
+ const lines = content.split('\n').map((l) => l.trim());
63
+
64
+ for (const pattern of REQUIRED_PATTERNS) {
65
+ if (!lines.includes(pattern)) {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ return true;
71
+ }
72
+
73
+ /**
74
+ * Ensure required .gitignore patterns are present.
75
+ *
76
+ * - Creates .gitignore if it doesn't exist
77
+ * - Appends missing patterns if .gitignore exists
78
+ * - Does nothing if all patterns are already present
79
+ *
80
+ * @returns Object with updated flag and descriptive message
81
+ */
82
+ async ensureGitignorePatterns(): Promise<{ updated: boolean; message: string }> {
83
+ const exists = await fileExists(this.gitignorePath);
84
+
85
+ if (!exists) {
86
+ // Create new .gitignore with our patterns
87
+ const content = `${SECTION_HEADER.trim()}\n${REQUIRED_PATTERNS.join('\n')}\n`;
88
+ await writeFile(this.gitignorePath, content);
89
+ return {
90
+ updated: true,
91
+ message: 'Created .gitignore with Bluera Knowledge patterns',
92
+ };
93
+ }
94
+
95
+ // Read existing content
96
+ const existingContent = await readFile(this.gitignorePath, 'utf-8');
97
+ const lines = existingContent.split('\n').map((l) => l.trim());
98
+
99
+ // Find missing patterns
100
+ const missingPatterns = REQUIRED_PATTERNS.filter((pattern) => !lines.includes(pattern));
101
+
102
+ if (missingPatterns.length === 0) {
103
+ return {
104
+ updated: false,
105
+ message: 'All Bluera Knowledge patterns already present in .gitignore',
106
+ };
107
+ }
108
+
109
+ // Append missing patterns
110
+ let newContent = existingContent;
111
+ if (!newContent.endsWith('\n')) {
112
+ newContent += '\n';
113
+ }
114
+
115
+ newContent += SECTION_HEADER;
116
+ newContent += `${missingPatterns.join('\n')}\n`;
117
+
118
+ await writeFile(this.gitignorePath, newContent);
119
+
120
+ return {
121
+ updated: true,
122
+ message: `Updated .gitignore with ${String(missingPatterns.length)} Bluera Knowledge pattern(s)`,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Get the path to the .gitignore file
128
+ */
129
+ getGitignorePath(): string {
130
+ return this.gitignorePath;
131
+ }
132
+ }
@@ -0,0 +1,440 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { StoreDefinitionService } from './store-definition.service.js';
3
+ import { rm, mkdtemp, writeFile, readFile, mkdir, access } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, dirname } from 'node:path';
6
+ import type { StoreDefinitionsConfig, FileStoreDefinition } from '../types/store-definition.js';
7
+
8
+ /**
9
+ * Helper to create isolated test context.
10
+ * Each test creates its own temp directory to ensure complete isolation.
11
+ */
12
+ async function createTestContext(): Promise<{
13
+ service: StoreDefinitionService;
14
+ tempDir: string;
15
+ cleanup: () => Promise<void>;
16
+ }> {
17
+ const tempDir = await mkdtemp(join(tmpdir(), 'store-def-test-'));
18
+ const service = new StoreDefinitionService(tempDir);
19
+ return {
20
+ service,
21
+ tempDir,
22
+ cleanup: async () => {
23
+ await rm(tempDir, { recursive: true, force: true });
24
+ },
25
+ };
26
+ }
27
+
28
+ describe('StoreDefinitionService', () => {
29
+ describe('load', () => {
30
+ it('returns empty config when file does not exist', async () => {
31
+ const ctx = await createTestContext();
32
+ try {
33
+ const config = await ctx.service.load();
34
+ expect(config.version).toBe(1);
35
+ expect(config.stores).toEqual([]);
36
+ } finally {
37
+ await ctx.cleanup();
38
+ }
39
+ });
40
+
41
+ it('loads existing config file', async () => {
42
+ const ctx = await createTestContext();
43
+ try {
44
+ const configPath = join(ctx.tempDir, '.bluera/bluera-knowledge/stores.config.json');
45
+ await mkdir(dirname(configPath), { recursive: true });
46
+ const existingConfig: StoreDefinitionsConfig = {
47
+ version: 1,
48
+ stores: [{ type: 'file', name: 'test', path: './test' }],
49
+ };
50
+ await writeFile(configPath, JSON.stringify(existingConfig));
51
+
52
+ const config = await ctx.service.load();
53
+ expect(config.stores).toHaveLength(1);
54
+ expect(config.stores[0].name).toBe('test');
55
+ } finally {
56
+ await ctx.cleanup();
57
+ }
58
+ });
59
+
60
+ it('throws on invalid JSON (fail fast per CLAUDE.md)', async () => {
61
+ const ctx = await createTestContext();
62
+ try {
63
+ const configPath = join(ctx.tempDir, '.bluera/bluera-knowledge/stores.config.json');
64
+ await mkdir(dirname(configPath), { recursive: true });
65
+ await writeFile(configPath, '{invalid json');
66
+
67
+ await expect(ctx.service.load()).rejects.toThrow(/parse|JSON/i);
68
+ } finally {
69
+ await ctx.cleanup();
70
+ }
71
+ });
72
+
73
+ it('throws on invalid schema (fail fast per CLAUDE.md)', async () => {
74
+ const ctx = await createTestContext();
75
+ try {
76
+ const configPath = join(ctx.tempDir, '.bluera/bluera-knowledge/stores.config.json');
77
+ await mkdir(dirname(configPath), { recursive: true });
78
+ await writeFile(configPath, JSON.stringify({ version: 1, stores: [{ type: 'invalid' }] }));
79
+
80
+ await expect(ctx.service.load()).rejects.toThrow();
81
+ } finally {
82
+ await ctx.cleanup();
83
+ }
84
+ });
85
+
86
+ it('throws on wrong version (fail fast per CLAUDE.md)', async () => {
87
+ const ctx = await createTestContext();
88
+ try {
89
+ const configPath = join(ctx.tempDir, '.bluera/bluera-knowledge/stores.config.json');
90
+ await mkdir(dirname(configPath), { recursive: true });
91
+ await writeFile(configPath, JSON.stringify({ version: 99, stores: [] }));
92
+
93
+ await expect(ctx.service.load()).rejects.toThrow();
94
+ } finally {
95
+ await ctx.cleanup();
96
+ }
97
+ });
98
+
99
+ it('caches loaded config', async () => {
100
+ const ctx = await createTestContext();
101
+ try {
102
+ const config1 = await ctx.service.load();
103
+ const config2 = await ctx.service.load();
104
+ expect(config1).toBe(config2); // Same reference
105
+ } finally {
106
+ await ctx.cleanup();
107
+ }
108
+ });
109
+ });
110
+
111
+ describe('save', () => {
112
+ it('creates directory if not exists', async () => {
113
+ const ctx = await createTestContext();
114
+ try {
115
+ const config: StoreDefinitionsConfig = { version: 1, stores: [] };
116
+ await ctx.service.save(config);
117
+
118
+ const configPath = ctx.service.getConfigPath();
119
+ await expect(access(configPath)).resolves.toBeUndefined();
120
+ } finally {
121
+ await ctx.cleanup();
122
+ }
123
+ });
124
+
125
+ it('writes formatted JSON', async () => {
126
+ const ctx = await createTestContext();
127
+ try {
128
+ const config: StoreDefinitionsConfig = {
129
+ version: 1,
130
+ stores: [{ type: 'file', name: 'test', path: './test' }],
131
+ };
132
+ await ctx.service.save(config);
133
+
134
+ const content = await readFile(ctx.service.getConfigPath(), 'utf-8');
135
+ expect(content).toContain('\n'); // Formatted with newlines
136
+ expect(content).toContain('"version": 1');
137
+ } finally {
138
+ await ctx.cleanup();
139
+ }
140
+ });
141
+
142
+ it('updates cache after save', async () => {
143
+ const ctx = await createTestContext();
144
+ try {
145
+ const config: StoreDefinitionsConfig = {
146
+ version: 1,
147
+ stores: [{ type: 'file', name: 'test', path: './test' }],
148
+ };
149
+ await ctx.service.save(config);
150
+
151
+ const loaded = await ctx.service.load();
152
+ expect(loaded.stores).toHaveLength(1);
153
+ } finally {
154
+ await ctx.cleanup();
155
+ }
156
+ });
157
+ });
158
+
159
+ describe('addDefinition', () => {
160
+ it('adds definition to empty config', async () => {
161
+ const ctx = await createTestContext();
162
+ try {
163
+ const definition: FileStoreDefinition = {
164
+ type: 'file',
165
+ name: 'docs',
166
+ path: './docs',
167
+ };
168
+ await ctx.service.addDefinition(definition);
169
+
170
+ const config = await ctx.service.load();
171
+ expect(config.stores).toHaveLength(1);
172
+ expect(config.stores[0].name).toBe('docs');
173
+ } finally {
174
+ await ctx.cleanup();
175
+ }
176
+ });
177
+
178
+ it('adds definition to existing config', async () => {
179
+ const ctx = await createTestContext();
180
+ try {
181
+ // Pre-populate
182
+ await ctx.service.save({
183
+ version: 1,
184
+ stores: [{ type: 'file', name: 'existing', path: './existing' }],
185
+ });
186
+ // Clear cache to force reload
187
+ ctx.service.clearCache();
188
+
189
+ // Add new
190
+ await ctx.service.addDefinition({ type: 'file', name: 'new', path: './new' });
191
+
192
+ const config = await ctx.service.load();
193
+ expect(config.stores).toHaveLength(2);
194
+ } finally {
195
+ await ctx.cleanup();
196
+ }
197
+ });
198
+
199
+ it('throws when adding duplicate name', async () => {
200
+ const ctx = await createTestContext();
201
+ try {
202
+ await ctx.service.addDefinition({ type: 'file', name: 'docs', path: './docs' });
203
+
204
+ await expect(
205
+ ctx.service.addDefinition({ type: 'file', name: 'docs', path: './other' })
206
+ ).rejects.toThrow(/already exists/i);
207
+ } finally {
208
+ await ctx.cleanup();
209
+ }
210
+ });
211
+
212
+ it('persists to file', async () => {
213
+ const ctx = await createTestContext();
214
+ try {
215
+ await ctx.service.addDefinition({ type: 'file', name: 'docs', path: './docs' });
216
+
217
+ // Create fresh service (no cache)
218
+ const freshService = new StoreDefinitionService(ctx.tempDir);
219
+ const config = await freshService.load();
220
+ expect(config.stores).toHaveLength(1);
221
+ } finally {
222
+ await ctx.cleanup();
223
+ }
224
+ });
225
+ });
226
+
227
+ describe('removeDefinition', () => {
228
+ it('removes existing definition', async () => {
229
+ const ctx = await createTestContext();
230
+ try {
231
+ await ctx.service.addDefinition({ type: 'file', name: 'docs', path: './docs' });
232
+ await ctx.service.addDefinition({ type: 'file', name: 'other', path: './other' });
233
+
234
+ const removed = await ctx.service.removeDefinition('docs');
235
+ expect(removed).toBe(true);
236
+
237
+ const config = await ctx.service.load();
238
+ expect(config.stores).toHaveLength(1);
239
+ expect(config.stores[0].name).toBe('other');
240
+ } finally {
241
+ await ctx.cleanup();
242
+ }
243
+ });
244
+
245
+ it('returns false for non-existent name', async () => {
246
+ const ctx = await createTestContext();
247
+ try {
248
+ const removed = await ctx.service.removeDefinition('nonexistent');
249
+ expect(removed).toBe(false);
250
+ } finally {
251
+ await ctx.cleanup();
252
+ }
253
+ });
254
+
255
+ it('persists removal to file', async () => {
256
+ const ctx = await createTestContext();
257
+ try {
258
+ await ctx.service.addDefinition({ type: 'file', name: 'docs', path: './docs' });
259
+ await ctx.service.removeDefinition('docs');
260
+
261
+ // Create fresh service
262
+ const freshService = new StoreDefinitionService(ctx.tempDir);
263
+ const config = await freshService.load();
264
+ expect(config.stores).toHaveLength(0);
265
+ } finally {
266
+ await ctx.cleanup();
267
+ }
268
+ });
269
+ });
270
+
271
+ describe('updateDefinition', () => {
272
+ it('updates existing definition', async () => {
273
+ const ctx = await createTestContext();
274
+ try {
275
+ await ctx.service.addDefinition({
276
+ type: 'file',
277
+ name: 'docs',
278
+ path: './docs',
279
+ description: 'old',
280
+ });
281
+
282
+ await ctx.service.updateDefinition('docs', { description: 'new description' });
283
+
284
+ const config = await ctx.service.load();
285
+ expect(config.stores[0].description).toBe('new description');
286
+ } finally {
287
+ await ctx.cleanup();
288
+ }
289
+ });
290
+
291
+ it('preserves unchanged fields', async () => {
292
+ const ctx = await createTestContext();
293
+ try {
294
+ await ctx.service.addDefinition({
295
+ type: 'file',
296
+ name: 'docs',
297
+ path: './docs',
298
+ description: 'desc',
299
+ tags: ['tag1'],
300
+ });
301
+
302
+ await ctx.service.updateDefinition('docs', { description: 'updated' });
303
+
304
+ const config = await ctx.service.load();
305
+ const store = config.stores[0] as FileStoreDefinition;
306
+ expect(store.path).toBe('./docs');
307
+ expect(store.tags).toEqual(['tag1']);
308
+ } finally {
309
+ await ctx.cleanup();
310
+ }
311
+ });
312
+
313
+ it('throws for non-existent definition', async () => {
314
+ const ctx = await createTestContext();
315
+ try {
316
+ await expect(
317
+ ctx.service.updateDefinition('nonexistent', { description: 'x' })
318
+ ).rejects.toThrow(/not found/i);
319
+ } finally {
320
+ await ctx.cleanup();
321
+ }
322
+ });
323
+
324
+ it('persists update to file', async () => {
325
+ const ctx = await createTestContext();
326
+ try {
327
+ await ctx.service.addDefinition({ type: 'file', name: 'docs', path: './docs' });
328
+ await ctx.service.updateDefinition('docs', { description: 'updated' });
329
+
330
+ const freshService = new StoreDefinitionService(ctx.tempDir);
331
+ const config = await freshService.load();
332
+ expect(config.stores[0].description).toBe('updated');
333
+ } finally {
334
+ await ctx.cleanup();
335
+ }
336
+ });
337
+ });
338
+
339
+ describe('getByName', () => {
340
+ it('returns definition by name', async () => {
341
+ const ctx = await createTestContext();
342
+ try {
343
+ await ctx.service.addDefinition({ type: 'file', name: 'docs', path: './docs' });
344
+
345
+ const def = await ctx.service.getByName('docs');
346
+ expect(def).toBeDefined();
347
+ expect(def?.name).toBe('docs');
348
+ } finally {
349
+ await ctx.cleanup();
350
+ }
351
+ });
352
+
353
+ it('returns undefined for non-existent name', async () => {
354
+ const ctx = await createTestContext();
355
+ try {
356
+ const def = await ctx.service.getByName('nonexistent');
357
+ expect(def).toBeUndefined();
358
+ } finally {
359
+ await ctx.cleanup();
360
+ }
361
+ });
362
+ });
363
+
364
+ describe('resolvePath', () => {
365
+ it('resolves relative paths against project root', async () => {
366
+ const ctx = await createTestContext();
367
+ try {
368
+ const resolved = ctx.service.resolvePath('./docs');
369
+ expect(resolved).toBe(join(ctx.tempDir, 'docs'));
370
+ } finally {
371
+ await ctx.cleanup();
372
+ }
373
+ });
374
+
375
+ it('resolves paths starting with ./ against project root', async () => {
376
+ const ctx = await createTestContext();
377
+ try {
378
+ const resolved = ctx.service.resolvePath('./src/utils');
379
+ expect(resolved).toBe(join(ctx.tempDir, 'src/utils'));
380
+ } finally {
381
+ await ctx.cleanup();
382
+ }
383
+ });
384
+
385
+ it('resolves paths without leading ./ against project root', async () => {
386
+ const ctx = await createTestContext();
387
+ try {
388
+ const resolved = ctx.service.resolvePath('docs');
389
+ expect(resolved).toBe(join(ctx.tempDir, 'docs'));
390
+ } finally {
391
+ await ctx.cleanup();
392
+ }
393
+ });
394
+
395
+ it('keeps absolute paths as-is', async () => {
396
+ const ctx = await createTestContext();
397
+ try {
398
+ const resolved = ctx.service.resolvePath('/absolute/path');
399
+ expect(resolved).toBe('/absolute/path');
400
+ } finally {
401
+ await ctx.cleanup();
402
+ }
403
+ });
404
+ });
405
+
406
+ describe('getConfigPath', () => {
407
+ it('returns correct config path', async () => {
408
+ const ctx = await createTestContext();
409
+ try {
410
+ const configPath = ctx.service.getConfigPath();
411
+ expect(configPath).toBe(join(ctx.tempDir, '.bluera/bluera-knowledge/stores.config.json'));
412
+ } finally {
413
+ await ctx.cleanup();
414
+ }
415
+ });
416
+ });
417
+
418
+ describe('hasDefinitions', () => {
419
+ it('returns false when no definitions', async () => {
420
+ const ctx = await createTestContext();
421
+ try {
422
+ const has = await ctx.service.hasDefinitions();
423
+ expect(has).toBe(false);
424
+ } finally {
425
+ await ctx.cleanup();
426
+ }
427
+ });
428
+
429
+ it('returns true when definitions exist', async () => {
430
+ const ctx = await createTestContext();
431
+ try {
432
+ await ctx.service.addDefinition({ type: 'file', name: 'docs', path: './docs' });
433
+ const has = await ctx.service.hasDefinitions();
434
+ expect(has).toBe(true);
435
+ } finally {
436
+ await ctx.cleanup();
437
+ }
438
+ });
439
+ });
440
+ });