tlc-claude-code 1.8.5 → 2.1.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.
Files changed (138) hide show
  1. package/.claude/commands/tlc/bootstrap.md +77 -0
  2. package/.claude/commands/tlc/build.md +20 -6
  3. package/.claude/commands/tlc/deploy.md +194 -2
  4. package/.claude/commands/tlc/e2e-verify.md +214 -0
  5. package/.claude/commands/tlc/guard.md +191 -0
  6. package/.claude/commands/tlc/help.md +32 -0
  7. package/.claude/commands/tlc/init.md +73 -37
  8. package/.claude/commands/tlc/llm.md +19 -4
  9. package/.claude/commands/tlc/preflight.md +134 -0
  10. package/.claude/commands/tlc/recall.md +87 -0
  11. package/.claude/commands/tlc/remember.md +71 -0
  12. package/.claude/commands/tlc/review.md +17 -4
  13. package/.claude/commands/tlc/watchci.md +159 -0
  14. package/.claude/hooks/tlc-block-tools.sh +41 -0
  15. package/.claude/hooks/tlc-capture-exchange.sh +50 -0
  16. package/.claude/hooks/tlc-post-build.sh +38 -0
  17. package/.claude/hooks/tlc-post-push.sh +22 -0
  18. package/.claude/hooks/tlc-prompt-guard.sh +69 -0
  19. package/.claude/hooks/tlc-session-init.sh +123 -0
  20. package/CLAUDE.md +96 -201
  21. package/bin/install.js +171 -2
  22. package/bin/postinstall.js +45 -26
  23. package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
  24. package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
  25. package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
  26. package/dashboard-web/dist/index.html +2 -2
  27. package/docker-compose.dev.yml +18 -12
  28. package/package.json +3 -1
  29. package/server/index.js +240 -1
  30. package/server/lib/bug-writer.js +204 -0
  31. package/server/lib/bug-writer.test.js +279 -0
  32. package/server/lib/capture-bridge.js +242 -0
  33. package/server/lib/capture-bridge.test.js +363 -0
  34. package/server/lib/capture-guard.js +140 -0
  35. package/server/lib/capture-guard.test.js +182 -0
  36. package/server/lib/claude-cascade.js +247 -0
  37. package/server/lib/claude-cascade.test.js +245 -0
  38. package/server/lib/command-runner.js +159 -0
  39. package/server/lib/command-runner.test.js +92 -0
  40. package/server/lib/context-injection.js +121 -0
  41. package/server/lib/context-injection.test.js +340 -0
  42. package/server/lib/conversation-chunker.js +320 -0
  43. package/server/lib/conversation-chunker.test.js +573 -0
  44. package/server/lib/deploy/runners/dependency-runner.js +106 -0
  45. package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
  46. package/server/lib/deploy/runners/secrets-runner.js +174 -0
  47. package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
  48. package/server/lib/deploy/security-gates.js +11 -24
  49. package/server/lib/deploy/security-gates.test.js +9 -2
  50. package/server/lib/deploy-engine.js +182 -0
  51. package/server/lib/deploy-engine.test.js +147 -0
  52. package/server/lib/docker-api.js +137 -0
  53. package/server/lib/docker-api.test.js +202 -0
  54. package/server/lib/docker-client.js +297 -0
  55. package/server/lib/docker-client.test.js +308 -0
  56. package/server/lib/embedding-client.js +160 -0
  57. package/server/lib/embedding-client.test.js +243 -0
  58. package/server/lib/global-config.js +198 -0
  59. package/server/lib/global-config.test.js +288 -0
  60. package/server/lib/inherited-search.js +184 -0
  61. package/server/lib/inherited-search.test.js +343 -0
  62. package/server/lib/input-sanitizer.js +86 -0
  63. package/server/lib/input-sanitizer.test.js +117 -0
  64. package/server/lib/launchd-agent.js +225 -0
  65. package/server/lib/launchd-agent.test.js +185 -0
  66. package/server/lib/memory-api.js +182 -0
  67. package/server/lib/memory-api.test.js +320 -0
  68. package/server/lib/memory-bridge-e2e.test.js +160 -0
  69. package/server/lib/memory-committer.js +18 -4
  70. package/server/lib/memory-committer.test.js +21 -0
  71. package/server/lib/memory-hooks-capture.test.js +415 -0
  72. package/server/lib/memory-hooks-integration.test.js +98 -0
  73. package/server/lib/memory-hooks.js +139 -0
  74. package/server/lib/memory-inheritance.js +179 -0
  75. package/server/lib/memory-inheritance.test.js +360 -0
  76. package/server/lib/memory-store-adapter.js +105 -0
  77. package/server/lib/memory-store-adapter.test.js +141 -0
  78. package/server/lib/memory-wiring-e2e.test.js +93 -0
  79. package/server/lib/nginx-config.js +114 -0
  80. package/server/lib/nginx-config.test.js +82 -0
  81. package/server/lib/ollama-health.js +91 -0
  82. package/server/lib/ollama-health.test.js +74 -0
  83. package/server/lib/plan-writer.js +196 -0
  84. package/server/lib/plan-writer.test.js +298 -0
  85. package/server/lib/port-guard.js +44 -0
  86. package/server/lib/port-guard.test.js +65 -0
  87. package/server/lib/project-scanner.js +302 -0
  88. package/server/lib/project-scanner.test.js +541 -0
  89. package/server/lib/project-status.js +302 -0
  90. package/server/lib/project-status.test.js +470 -0
  91. package/server/lib/projects-registry.js +237 -0
  92. package/server/lib/projects-registry.test.js +275 -0
  93. package/server/lib/recall-command.js +207 -0
  94. package/server/lib/recall-command.test.js +306 -0
  95. package/server/lib/remember-command.js +98 -0
  96. package/server/lib/remember-command.test.js +288 -0
  97. package/server/lib/rich-capture.js +221 -0
  98. package/server/lib/rich-capture.test.js +312 -0
  99. package/server/lib/roadmap-api.js +200 -0
  100. package/server/lib/roadmap-api.test.js +318 -0
  101. package/server/lib/security/crypto-utils.test.js +2 -2
  102. package/server/lib/semantic-recall.js +242 -0
  103. package/server/lib/semantic-recall.test.js +463 -0
  104. package/server/lib/setup-generator.js +315 -0
  105. package/server/lib/setup-generator.test.js +303 -0
  106. package/server/lib/ssh-client.js +184 -0
  107. package/server/lib/ssh-client.test.js +127 -0
  108. package/server/lib/test-inventory.js +112 -0
  109. package/server/lib/test-inventory.test.js +360 -0
  110. package/server/lib/vector-indexer.js +246 -0
  111. package/server/lib/vector-indexer.test.js +459 -0
  112. package/server/lib/vector-store.js +260 -0
  113. package/server/lib/vector-store.test.js +706 -0
  114. package/server/lib/vps-api.js +184 -0
  115. package/server/lib/vps-api.test.js +208 -0
  116. package/server/lib/vps-bootstrap.js +124 -0
  117. package/server/lib/vps-bootstrap.test.js +79 -0
  118. package/server/lib/vps-monitor.js +126 -0
  119. package/server/lib/vps-monitor.test.js +98 -0
  120. package/server/lib/workspace-api.js +992 -0
  121. package/server/lib/workspace-api.test.js +1217 -0
  122. package/server/lib/workspace-bootstrap.js +164 -0
  123. package/server/lib/workspace-bootstrap.test.js +503 -0
  124. package/server/lib/workspace-context.js +129 -0
  125. package/server/lib/workspace-context.test.js +214 -0
  126. package/server/lib/workspace-detector.js +162 -0
  127. package/server/lib/workspace-detector.test.js +193 -0
  128. package/server/lib/workspace-init.js +307 -0
  129. package/server/lib/workspace-init.test.js +244 -0
  130. package/server/lib/workspace-snapshot.js +236 -0
  131. package/server/lib/workspace-snapshot.test.js +444 -0
  132. package/server/lib/workspace-watcher.js +162 -0
  133. package/server/lib/workspace-watcher.test.js +257 -0
  134. package/server/package-lock.json +1306 -17
  135. package/server/package.json +7 -0
  136. package/dashboard-web/dist/assets/index-B1I_joSL.js +0 -393
  137. package/dashboard-web/dist/assets/index-B1I_joSL.js.map +0 -1
  138. package/dashboard-web/dist/assets/index-Trhg1C1Y.css +0 -1
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Embedding Client — Ollama-based text embedding with graceful degradation.
3
+ *
4
+ * Generates vector embeddings from text using a local Ollama instance.
5
+ * Returns null when Ollama is unavailable (caller falls back to text search).
6
+ *
7
+ * @module embedding-client
8
+ */
9
+
10
+ /** Max characters to send per embed request (~8192 tokens * 4 chars/token) */
11
+ const MAX_INPUT_CHARS = 32768;
12
+
13
+ /** Known model dimensions */
14
+ const MODEL_DIMENSIONS = {
15
+ 'mxbai-embed-large': 1024,
16
+ 'nomic-embed-text': 768,
17
+ 'all-minilm': 384,
18
+ };
19
+
20
+ /**
21
+ * Create an embedding client that talks to Ollama.
22
+ *
23
+ * @param {object} [options]
24
+ * @param {string} [options.host='http://localhost:11434'] - Ollama host URL
25
+ * @param {string} [options.model='mxbai-embed-large'] - Embedding model name
26
+ * @param {number} [options.timeout=30000] - Request timeout in ms
27
+ * @returns {object} Client with embed/embedBatch/isAvailable/getModelInfo
28
+ */
29
+ export function createEmbeddingClient(options = {}) {
30
+ const host = options.host || 'http://localhost:11434';
31
+ const model = options.model || 'mxbai-embed-large';
32
+ const timeout = options.timeout || 30000;
33
+
34
+ /**
35
+ * Truncate text to fit within model token limits.
36
+ */
37
+ function truncateText(text) {
38
+ if (text.length > MAX_INPUT_CHARS) {
39
+ return text.slice(0, MAX_INPUT_CHARS);
40
+ }
41
+ return text;
42
+ }
43
+
44
+ /**
45
+ * Embed a single text string.
46
+ * @param {string} text
47
+ * @returns {Promise<Float32Array|null>} Embedding or null if unavailable
48
+ */
49
+ async function embed(text) {
50
+ if (!text || text.length === 0) {
51
+ return null;
52
+ }
53
+
54
+ try {
55
+ const input = truncateText(text);
56
+ const controller = new AbortController();
57
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
58
+
59
+ const response = await fetch(`${host}/api/embed`, {
60
+ method: 'POST',
61
+ headers: { 'Content-Type': 'application/json' },
62
+ body: JSON.stringify({ model, input }),
63
+ signal: controller.signal,
64
+ });
65
+
66
+ clearTimeout(timeoutId);
67
+
68
+ if (!response.ok) {
69
+ return null;
70
+ }
71
+
72
+ const data = await response.json();
73
+ if (!data.embeddings || data.embeddings.length === 0) {
74
+ return null;
75
+ }
76
+
77
+ return new Float32Array(data.embeddings[0]);
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Embed multiple texts in a single batch request.
85
+ * @param {string[]} texts
86
+ * @returns {Promise<(Float32Array|null)[]>}
87
+ */
88
+ async function embedBatch(texts) {
89
+ if (!texts || texts.length === 0) {
90
+ return [];
91
+ }
92
+
93
+ try {
94
+ const inputs = texts.map(truncateText);
95
+ const controller = new AbortController();
96
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
97
+
98
+ const response = await fetch(`${host}/api/embed`, {
99
+ method: 'POST',
100
+ headers: { 'Content-Type': 'application/json' },
101
+ body: JSON.stringify({ model, input: inputs }),
102
+ signal: controller.signal,
103
+ });
104
+
105
+ clearTimeout(timeoutId);
106
+
107
+ if (!response.ok) {
108
+ return texts.map(() => null);
109
+ }
110
+
111
+ const data = await response.json();
112
+ if (!data.embeddings) {
113
+ return texts.map(() => null);
114
+ }
115
+
116
+ return data.embeddings.map((emb) => new Float32Array(emb));
117
+ } catch {
118
+ return texts.map(() => null);
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Check if Ollama is running and accessible.
124
+ * @returns {Promise<boolean>}
125
+ */
126
+ async function isAvailable() {
127
+ try {
128
+ const controller = new AbortController();
129
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
130
+
131
+ const response = await fetch(`${host}/api/tags`, {
132
+ signal: controller.signal,
133
+ });
134
+
135
+ clearTimeout(timeoutId);
136
+
137
+ return response.ok;
138
+ } catch {
139
+ return false;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Get info about the configured embedding model.
145
+ * @returns {{ model: string, dimensions: number }}
146
+ */
147
+ function getModelInfo() {
148
+ return {
149
+ model,
150
+ dimensions: MODEL_DIMENSIONS[model] || 1024,
151
+ };
152
+ }
153
+
154
+ return {
155
+ embed,
156
+ embedBatch,
157
+ isAvailable,
158
+ getModelInfo,
159
+ };
160
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Embedding Client Tests
3
+ * Tests for Ollama-based embedding client with graceful degradation
4
+ */
5
+
6
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
7
+ import { createEmbeddingClient } from './embedding-client.js';
8
+
9
+ describe('embedding-client', () => {
10
+ let client;
11
+ let originalFetch;
12
+
13
+ beforeEach(() => {
14
+ originalFetch = global.fetch;
15
+ global.fetch = vi.fn();
16
+ client = createEmbeddingClient();
17
+ });
18
+
19
+ afterEach(() => {
20
+ global.fetch = originalFetch;
21
+ vi.restoreAllMocks();
22
+ });
23
+
24
+ describe('embed', () => {
25
+ it('embeds text and returns Float32Array', async () => {
26
+ const mockEmbedding = Array.from({ length: 1024 }, (_, i) => i * 0.001);
27
+
28
+ global.fetch = vi.fn().mockResolvedValue({
29
+ ok: true,
30
+ json: () => Promise.resolve({
31
+ embeddings: [mockEmbedding],
32
+ }),
33
+ });
34
+
35
+ const result = await client.embed('hello world');
36
+
37
+ expect(result).toBeInstanceOf(Float32Array);
38
+ expect(global.fetch).toHaveBeenCalledWith(
39
+ 'http://localhost:11434/api/embed',
40
+ expect.objectContaining({
41
+ method: 'POST',
42
+ body: expect.any(String),
43
+ })
44
+ );
45
+
46
+ const body = JSON.parse(global.fetch.mock.calls[0][1].body);
47
+ expect(body.model).toBe('mxbai-embed-large');
48
+ expect(body.input).toBe('hello world');
49
+ });
50
+
51
+ it('returns correct dimensions for mxbai-embed-large (1024)', async () => {
52
+ const mockEmbedding = Array.from({ length: 1024 }, () => Math.random());
53
+
54
+ global.fetch = vi.fn().mockResolvedValue({
55
+ ok: true,
56
+ json: () => Promise.resolve({
57
+ embeddings: [mockEmbedding],
58
+ }),
59
+ });
60
+
61
+ const result = await client.embed('test text');
62
+
63
+ expect(result).toBeInstanceOf(Float32Array);
64
+ expect(result.length).toBe(1024);
65
+ });
66
+
67
+ it('returns null on connection failure (graceful degradation)', async () => {
68
+ global.fetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
69
+
70
+ const result = await client.embed('some text');
71
+
72
+ expect(result).toBeNull();
73
+ });
74
+
75
+ it('returns null for empty text', async () => {
76
+ const result = await client.embed('');
77
+
78
+ expect(result).toBeNull();
79
+ expect(global.fetch).not.toHaveBeenCalled();
80
+ });
81
+
82
+ it('truncates text exceeding token limit (8192 tokens ~ 32768 chars)', async () => {
83
+ const longText = 'a'.repeat(40000);
84
+ const mockEmbedding = Array.from({ length: 1024 }, () => 0.5);
85
+
86
+ global.fetch = vi.fn().mockResolvedValue({
87
+ ok: true,
88
+ json: () => Promise.resolve({
89
+ embeddings: [mockEmbedding],
90
+ }),
91
+ });
92
+
93
+ await client.embed(longText);
94
+
95
+ expect(global.fetch).toHaveBeenCalled();
96
+ const body = JSON.parse(global.fetch.mock.calls[0][1].body);
97
+ expect(body.input.length).toBeLessThanOrEqual(32768);
98
+ });
99
+
100
+ it('handles Ollama API error responses', async () => {
101
+ global.fetch = vi.fn().mockResolvedValue({
102
+ ok: false,
103
+ status: 500,
104
+ json: () => Promise.resolve({ error: 'model not found' }),
105
+ });
106
+
107
+ const result = await client.embed('test');
108
+
109
+ expect(result).toBeNull();
110
+ });
111
+
112
+ it('times out after 30s default', async () => {
113
+ global.fetch = vi.fn().mockImplementation((url, opts) => {
114
+ // Verify that an AbortSignal is passed for timeout control
115
+ expect(opts.signal).toBeDefined();
116
+ return new Promise((_, reject) => {
117
+ const error = new Error('The operation was aborted');
118
+ error.name = 'AbortError';
119
+ reject(error);
120
+ });
121
+ });
122
+
123
+ const result = await client.embed('test');
124
+
125
+ expect(result).toBeNull();
126
+ });
127
+ });
128
+
129
+ describe('embedBatch', () => {
130
+ it('batch embed processes multiple texts', async () => {
131
+ const mockEmbedding1 = Array.from({ length: 1024 }, () => 0.1);
132
+ const mockEmbedding2 = Array.from({ length: 1024 }, () => 0.2);
133
+ const mockEmbedding3 = Array.from({ length: 1024 }, () => 0.3);
134
+
135
+ global.fetch = vi.fn().mockResolvedValue({
136
+ ok: true,
137
+ json: () => Promise.resolve({
138
+ embeddings: [mockEmbedding1, mockEmbedding2, mockEmbedding3],
139
+ }),
140
+ });
141
+
142
+ const results = await client.embedBatch(['text one', 'text two', 'text three']);
143
+
144
+ expect(results).toHaveLength(3);
145
+ });
146
+
147
+ it('batch embed returns array of Float32Arrays', async () => {
148
+ const mockEmbedding1 = Array.from({ length: 1024 }, () => 0.1);
149
+ const mockEmbedding2 = Array.from({ length: 1024 }, () => 0.2);
150
+
151
+ global.fetch = vi.fn().mockResolvedValue({
152
+ ok: true,
153
+ json: () => Promise.resolve({
154
+ embeddings: [mockEmbedding1, mockEmbedding2],
155
+ }),
156
+ });
157
+
158
+ const results = await client.embedBatch(['hello', 'world']);
159
+
160
+ expect(results).toHaveLength(2);
161
+ results.forEach(result => {
162
+ expect(result).toBeInstanceOf(Float32Array);
163
+ expect(result.length).toBe(1024);
164
+ });
165
+ });
166
+ });
167
+
168
+ describe('isAvailable', () => {
169
+ it('returns true when Ollama is running', async () => {
170
+ global.fetch = vi.fn().mockResolvedValue({
171
+ ok: true,
172
+ json: () => Promise.resolve({
173
+ models: [{ name: 'mxbai-embed-large', size: 670000000 }],
174
+ }),
175
+ });
176
+
177
+ const available = await client.isAvailable();
178
+
179
+ expect(available).toBe(true);
180
+ expect(global.fetch).toHaveBeenCalledWith(
181
+ 'http://localhost:11434/api/tags',
182
+ expect.any(Object)
183
+ );
184
+ });
185
+
186
+ it('returns false when Ollama is not running', async () => {
187
+ global.fetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
188
+
189
+ const available = await client.isAvailable();
190
+
191
+ expect(available).toBe(false);
192
+ });
193
+ });
194
+
195
+ describe('getModelInfo', () => {
196
+ it('returns model name and dimensions', () => {
197
+ const info = client.getModelInfo();
198
+
199
+ expect(info).toEqual({
200
+ model: 'mxbai-embed-large',
201
+ dimensions: 1024,
202
+ });
203
+ });
204
+ });
205
+
206
+ describe('configuration', () => {
207
+ it('respects configurable model name', async () => {
208
+ const customClient = createEmbeddingClient({ model: 'nomic-embed-text' });
209
+ const mockEmbedding = Array.from({ length: 768 }, () => 0.5);
210
+
211
+ global.fetch = vi.fn().mockResolvedValue({
212
+ ok: true,
213
+ json: () => Promise.resolve({
214
+ embeddings: [mockEmbedding],
215
+ }),
216
+ });
217
+
218
+ await customClient.embed('test');
219
+
220
+ const body = JSON.parse(global.fetch.mock.calls[0][1].body);
221
+ expect(body.model).toBe('nomic-embed-text');
222
+ });
223
+
224
+ it('respects configurable host', async () => {
225
+ const customClient = createEmbeddingClient({ host: 'http://ollama.local:11434' });
226
+ const mockEmbedding = Array.from({ length: 1024 }, () => 0.5);
227
+
228
+ global.fetch = vi.fn().mockResolvedValue({
229
+ ok: true,
230
+ json: () => Promise.resolve({
231
+ embeddings: [mockEmbedding],
232
+ }),
233
+ });
234
+
235
+ await customClient.embed('test');
236
+
237
+ expect(global.fetch).toHaveBeenCalledWith(
238
+ 'http://ollama.local:11434/api/embed',
239
+ expect.any(Object)
240
+ );
241
+ });
242
+ });
243
+ });
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Global TLC Configuration - Persistent config at ~/.tlc/config.json
3
+ *
4
+ * Stores workspace root paths and settings that survive reinstalls.
5
+ * XDG-aware: uses $TLC_CONFIG_DIR or defaults to ~/.tlc/
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const os = require('os');
11
+
12
+ const CONFIG_FILENAME = 'config.json';
13
+
14
+ /**
15
+ * Get the config directory path
16
+ * @returns {string}
17
+ */
18
+ function getConfigDir() {
19
+ if (process.env.TLC_CONFIG_DIR) {
20
+ return process.env.TLC_CONFIG_DIR;
21
+ }
22
+ return path.join(os.homedir(), '.tlc');
23
+ }
24
+
25
+ /**
26
+ * Default config structure
27
+ * @returns {object}
28
+ */
29
+ function defaultConfig() {
30
+ return {
31
+ version: 1,
32
+ roots: [],
33
+ scanDepth: 5,
34
+ lastScans: {},
35
+ };
36
+ }
37
+
38
+ class GlobalConfig {
39
+ constructor() {
40
+ this.configDir = getConfigDir();
41
+ this.configPath = path.join(this.configDir, CONFIG_FILENAME);
42
+ this._config = null;
43
+ }
44
+
45
+ /**
46
+ * Load config from disk, creating defaults if needed
47
+ * @returns {object} The config object
48
+ */
49
+ load() {
50
+ this._ensureDir();
51
+
52
+ if (fs.existsSync(this.configPath)) {
53
+ try {
54
+ const raw = fs.readFileSync(this.configPath, 'utf-8');
55
+ this._config = JSON.parse(raw);
56
+ return this._config;
57
+ } catch (err) {
58
+ // Corrupted JSON — reset to defaults
59
+ console.error('Corrupted config, resetting to defaults:', err.message);
60
+ this._config = defaultConfig();
61
+ this._save();
62
+ return this._config;
63
+ }
64
+ }
65
+
66
+ // First access — create defaults
67
+ this._config = defaultConfig();
68
+ this._save();
69
+ return this._config;
70
+ }
71
+
72
+ /**
73
+ * Get all configured root paths
74
+ * @returns {string[]}
75
+ */
76
+ getRoots() {
77
+ this._ensureLoaded();
78
+ return [...this._config.roots];
79
+ }
80
+
81
+ /**
82
+ * Add a root directory path
83
+ * @param {string} rootPath - Absolute path to a directory
84
+ * @throws {Error} If path is invalid
85
+ */
86
+ addRoot(rootPath) {
87
+ this._ensureLoaded();
88
+
89
+ const resolved = path.resolve(rootPath);
90
+
91
+ if (!fs.existsSync(resolved)) {
92
+ throw new Error(`Path does not exist: ${resolved}`);
93
+ }
94
+
95
+ const stat = fs.statSync(resolved);
96
+ if (!stat.isDirectory()) {
97
+ throw new Error(`Path is not a directory: ${resolved}`);
98
+ }
99
+
100
+ if (this._config.roots.includes(resolved)) {
101
+ throw new Error(`Root already configured: ${resolved}`);
102
+ }
103
+
104
+ this._config.roots.push(resolved);
105
+ this._save();
106
+ }
107
+
108
+ /**
109
+ * Remove a root directory path
110
+ * @param {string} rootPath - Path to remove
111
+ */
112
+ removeRoot(rootPath) {
113
+ this._ensureLoaded();
114
+
115
+ const resolved = path.resolve(rootPath);
116
+ this._config.roots = this._config.roots.filter((r) => r !== resolved);
117
+
118
+ // Clean up lastScans entry
119
+ delete this._config.lastScans[resolved];
120
+
121
+ this._save();
122
+ }
123
+
124
+ /**
125
+ * Check if any roots are configured
126
+ * @returns {boolean}
127
+ */
128
+ isConfigured() {
129
+ this._ensureLoaded();
130
+ return this._config.roots.length > 0;
131
+ }
132
+
133
+ /**
134
+ * Set scan depth
135
+ * @param {number} depth
136
+ */
137
+ setScanDepth(depth) {
138
+ this._ensureLoaded();
139
+ this._config.scanDepth = depth;
140
+ this._save();
141
+ }
142
+
143
+ /**
144
+ * Set last scan timestamp for a root
145
+ * @param {string} rootPath
146
+ * @param {number} timestamp
147
+ */
148
+ setLastScan(rootPath, timestamp) {
149
+ this._ensureLoaded();
150
+ const resolved = path.resolve(rootPath);
151
+ this._config.lastScans[resolved] = timestamp;
152
+ this._save();
153
+ }
154
+
155
+ /**
156
+ * Get last scan timestamp for a root
157
+ * @param {string} rootPath
158
+ * @returns {number|null}
159
+ */
160
+ getLastScan(rootPath) {
161
+ this._ensureLoaded();
162
+ const resolved = path.resolve(rootPath);
163
+ return this._config.lastScans[resolved] || null;
164
+ }
165
+
166
+ /**
167
+ * Ensure config directory exists
168
+ * @private
169
+ */
170
+ _ensureDir() {
171
+ if (!fs.existsSync(this.configDir)) {
172
+ fs.mkdirSync(this.configDir, { recursive: true });
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Ensure config is loaded
178
+ * @private
179
+ */
180
+ _ensureLoaded() {
181
+ if (!this._config) {
182
+ this.load();
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Atomic write to config file
188
+ * @private
189
+ */
190
+ _save() {
191
+ this._ensureDir();
192
+ const tmpPath = this.configPath + '.tmp';
193
+ fs.writeFileSync(tmpPath, JSON.stringify(this._config, null, 2), 'utf-8');
194
+ fs.renameSync(tmpPath, this.configPath);
195
+ }
196
+ }
197
+
198
+ module.exports = { GlobalConfig };