contextstore-app 1.0.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.
package/dist/index.js ADDED
@@ -0,0 +1,874 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import * as p from '@clack/prompts';
4
+ import * as fs from 'fs';
5
+ import * as path from 'path';
6
+ import * as http from 'http';
7
+ import { exec } from 'child_process';
8
+ import { randomBytes } from 'crypto';
9
+ import { loadConfig, saveConfig } from './config.js';
10
+ import { detectProjects } from './detector.js';
11
+ import { walkWorkspace } from './walker.js';
12
+ import { detectProjectType } from './type-detector.js';
13
+ import { syncFile } from './syncer.js';
14
+ import { startWatcher } from './watcher.js';
15
+ // Open the browser pointing to URL dynamically depending on the OS
16
+ function openBrowser(url) {
17
+ const platform = process.platform;
18
+ let cmd = '';
19
+ if (platform === 'win32') {
20
+ // PowerShell handles ampersands and special characters in URLs beautifully.
21
+ cmd = `powershell -Command "Start-Process '${url}'"`;
22
+ }
23
+ else if (platform === 'darwin') {
24
+ cmd = `open "${url}"`;
25
+ }
26
+ else {
27
+ cmd = `xdg-open "${url}"`;
28
+ }
29
+ exec(cmd, (err) => {
30
+ if (err) {
31
+ // Fallback
32
+ }
33
+ });
34
+ }
35
+ function getSuccessHtml(email) {
36
+ const emailText = email ? `<div class="email">${email}</div>` : '';
37
+ return `
38
+ <!DOCTYPE html>
39
+ <html lang="en">
40
+ <head>
41
+ <meta charset="UTF-8">
42
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
43
+ <title>Login Successful — ContextStore</title>
44
+ <link rel="preconnect" href="https://fonts.googleapis.com">
45
+ <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&display=swap" rel="stylesheet">
46
+ <style>
47
+ :root {
48
+ --bg: #0c0a09;
49
+ --card-bg: rgba(28, 25, 23, 0.6);
50
+ --amber: #f59e0b;
51
+ --amber-glowing: rgba(245, 158, 11, 0.15);
52
+ --green: #10b981;
53
+ --green-glowing: rgba(16, 185, 129, 0.2);
54
+ --text: #fafaf9;
55
+ --text-muted: #a8a29e;
56
+ --border: rgba(120, 113, 108, 0.2);
57
+ }
58
+ * { box-sizing: border-box; margin: 0; padding: 0; }
59
+ body {
60
+ background: var(--bg);
61
+ background-image: radial-gradient(circle at 50% -20%, #78350f 0%, #0c0a09 70%);
62
+ color: var(--text);
63
+ font-family: 'DM Sans', sans-serif;
64
+ min-height: 100vh;
65
+ display: flex;
66
+ align-items: center;
67
+ justify-content: center;
68
+ padding: 24px;
69
+ overflow: hidden;
70
+ }
71
+ .card {
72
+ background: var(--card-bg);
73
+ backdrop-filter: blur(16px);
74
+ border: 1px solid var(--border);
75
+ border-radius: 24px;
76
+ width: 100%;
77
+ max-width: 440px;
78
+ padding: 48px 40px;
79
+ text-align: center;
80
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
81
+ animation: slideUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
82
+ }
83
+ @keyframes slideUp {
84
+ from { opacity: 0; transform: translateY(30px); }
85
+ to { opacity: 1; transform: translateY(0); }
86
+ }
87
+ .logo {
88
+ font-weight: 700;
89
+ font-size: 24px;
90
+ letter-spacing: -0.04em;
91
+ margin-bottom: 32px;
92
+ }
93
+ .logo span { color: var(--amber); }
94
+ .success-icon {
95
+ width: 80px;
96
+ height: 80px;
97
+ background: var(--green-glowing);
98
+ border: 2px solid var(--green);
99
+ border-radius: 50%;
100
+ display: flex;
101
+ align-items: center;
102
+ justify-content: center;
103
+ margin: 0 auto 24px;
104
+ animation: pulse 2s infinite;
105
+ }
106
+ @keyframes pulse {
107
+ 0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); }
108
+ 70% { box-shadow: 0 0 0 16px rgba(16, 185, 129, 0); }
109
+ 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
110
+ }
111
+ .success-icon svg {
112
+ width: 36px;
113
+ height: 36px;
114
+ color: var(--green);
115
+ stroke-dasharray: 100;
116
+ stroke-dashoffset: 100;
117
+ animation: drawCheck 0.6s 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
118
+ }
119
+ @keyframes drawCheck {
120
+ to { stroke-dashoffset: 0; }
121
+ }
122
+ h1 {
123
+ font-size: 24px;
124
+ font-weight: 700;
125
+ margin-bottom: 12px;
126
+ letter-spacing: -0.02em;
127
+ }
128
+ p {
129
+ color: var(--text-muted);
130
+ font-size: 15px;
131
+ line-height: 1.6;
132
+ margin-bottom: 24px;
133
+ }
134
+ .email {
135
+ background: rgba(255, 255, 255, 0.04);
136
+ border: 1px solid var(--border);
137
+ border-radius: 8px;
138
+ padding: 8px 16px;
139
+ display: inline-block;
140
+ font-size: 14px;
141
+ color: var(--amber);
142
+ font-weight: 500;
143
+ margin-bottom: 24px;
144
+ }
145
+ .footer {
146
+ border-top: 1px solid var(--border);
147
+ padding-top: 24px;
148
+ font-size: 13px;
149
+ color: var(--text-muted);
150
+ }
151
+ </style>
152
+ </head>
153
+ <body>
154
+ <div class="card">
155
+ <div class="logo">context<span>store</span></div>
156
+ <div class="success-icon">
157
+ <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
158
+ <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
159
+ </svg>
160
+ </div>
161
+ <h1>Login Successful!</h1>
162
+ <p>You have successfully authenticated the CLI.</p>
163
+ ${emailText}
164
+ <div class="footer">
165
+ You can now close this tab and return to your terminal.
166
+ </div>
167
+ </div>
168
+ </body>
169
+ </html>
170
+ `;
171
+ }
172
+ function getErrorHtml(errorMessage) {
173
+ return `
174
+ <!DOCTYPE html>
175
+ <html lang="en">
176
+ <head>
177
+ <meta charset="UTF-8">
178
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
179
+ <title>Login Failed — ContextStore</title>
180
+ <link rel="preconnect" href="https://fonts.googleapis.com">
181
+ <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&display=swap" rel="stylesheet">
182
+ <style>
183
+ :root {
184
+ --bg: #0c0a09;
185
+ --card-bg: rgba(28, 25, 23, 0.6);
186
+ --amber: #f59e0b;
187
+ --red: #ef4444;
188
+ --red-glowing: rgba(239, 68, 68, 0.15);
189
+ --text: #fafaf9;
190
+ --text-muted: #a8a29e;
191
+ --border: rgba(120, 113, 108, 0.2);
192
+ }
193
+ * { box-sizing: border-box; margin: 0; padding: 0; }
194
+ body {
195
+ background: var(--bg);
196
+ background-image: radial-gradient(circle at 50% -20%, #7f1d1d 0%, #0c0a09 70%);
197
+ color: var(--text);
198
+ font-family: 'DM Sans', sans-serif;
199
+ min-height: 100vh;
200
+ display: flex;
201
+ align-items: center;
202
+ justify-content: center;
203
+ padding: 24px;
204
+ overflow: hidden;
205
+ }
206
+ .card {
207
+ background: var(--card-bg);
208
+ backdrop-filter: blur(16px);
209
+ border: 1px solid var(--border);
210
+ border-radius: 24px;
211
+ width: 100%;
212
+ max-width: 440px;
213
+ padding: 48px 40px;
214
+ text-align: center;
215
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
216
+ animation: slideUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
217
+ }
218
+ @keyframes slideUp {
219
+ from { opacity: 0; transform: translateY(30px); }
220
+ to { opacity: 1; transform: translateY(0); }
221
+ }
222
+ .logo {
223
+ font-weight: 700;
224
+ font-size: 24px;
225
+ letter-spacing: -0.04em;
226
+ margin-bottom: 32px;
227
+ }
228
+ .logo span { color: var(--amber); }
229
+ .error-icon {
230
+ width: 80px;
231
+ height: 80px;
232
+ background: var(--red-glowing);
233
+ border: 2px solid var(--red);
234
+ border-radius: 50%;
235
+ display: flex;
236
+ align-items: center;
237
+ justify-content: center;
238
+ margin: 0 auto 24px;
239
+ }
240
+ .error-icon svg {
241
+ width: 36px;
242
+ height: 36px;
243
+ color: var(--red);
244
+ }
245
+ h1 {
246
+ font-size: 24px;
247
+ font-weight: 700;
248
+ margin-bottom: 12px;
249
+ letter-spacing: -0.02em;
250
+ }
251
+ p {
252
+ color: var(--text-muted);
253
+ font-size: 15px;
254
+ line-height: 1.6;
255
+ margin-bottom: 24px;
256
+ }
257
+ .err-msg {
258
+ background: rgba(239, 68, 68, 0.1);
259
+ border: 1px solid rgba(239, 68, 68, 0.2);
260
+ border-radius: 8px;
261
+ padding: 12px 16px;
262
+ font-size: 14px;
263
+ color: #fca5a5;
264
+ margin-bottom: 24px;
265
+ text-align: left;
266
+ font-family: monospace;
267
+ word-break: break-all;
268
+ }
269
+ .footer {
270
+ border-top: 1px solid var(--border);
271
+ padding-top: 24px;
272
+ font-size: 13px;
273
+ color: var(--text-muted);
274
+ }
275
+ </style>
276
+ </head>
277
+ <body>
278
+ <div class="card">
279
+ <div class="logo">context<span>store</span></div>
280
+ <div class="error-icon">
281
+ <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
282
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
283
+ </svg>
284
+ </div>
285
+ <h1>Authentication Failed</h1>
286
+ <p>Something went wrong during login verification.</p>
287
+ <div class="err-msg">${errorMessage}</div>
288
+ <div class="footer">
289
+ Please close this tab and try starting the login flow again in your terminal.
290
+ </div>
291
+ </div>
292
+ </body>
293
+ </html>
294
+ `;
295
+ }
296
+ // Start temporary local HTTP callback server and open browser for OAuth
297
+ async function startBrowserLoginFlow(config) {
298
+ return new Promise((resolve, reject) => {
299
+ const port = 4224;
300
+ const state = randomBytes(16).toString('hex');
301
+ const redirectUri = `http://localhost:${port}/callback`;
302
+ const authorizeUrl = `${config.serverUrl}/oauth/authorize?response_type=code&client_id=cli&redirect_uri=${encodeURIComponent(redirectUri)}&state=${state}`;
303
+ const server = http.createServer(async (req, res) => {
304
+ try {
305
+ const reqUrl = new URL(req.url || '', `http://${req.headers.host}`);
306
+ if (reqUrl.pathname === '/callback') {
307
+ const code = reqUrl.searchParams.get('code');
308
+ const returnedState = reqUrl.searchParams.get('state');
309
+ if (!code || returnedState !== state) {
310
+ res.writeHead(400, { 'Content-Type': 'text/html' });
311
+ res.end(getErrorHtml('Invalid OAuth state returned. High security warning.'));
312
+ server.close();
313
+ reject(new Error('OAuth state mismatch. Verification failed.'));
314
+ return;
315
+ }
316
+ // Exchange code for token
317
+ const tokenUrl = `${config.serverUrl}/oauth/token`;
318
+ const tokenResponse = await fetch(tokenUrl, {
319
+ method: 'POST',
320
+ headers: { 'Content-Type': 'application/json' },
321
+ body: JSON.stringify({
322
+ code,
323
+ grant_type: 'authorization_code'
324
+ })
325
+ });
326
+ if (!tokenResponse.ok) {
327
+ const errorText = await tokenResponse.text();
328
+ res.writeHead(400, { 'Content-Type': 'text/html' });
329
+ res.end(getErrorHtml('Failed to exchange authorization code for access token.'));
330
+ server.close();
331
+ reject(new Error(`Token exchange failed: ${errorText}`));
332
+ return;
333
+ }
334
+ const tokenData = await tokenResponse.json();
335
+ const accessToken = tokenData.access_token;
336
+ if (!accessToken) {
337
+ res.writeHead(400, { 'Content-Type': 'text/html' });
338
+ res.end(getErrorHtml('No access token returned by the server.'));
339
+ server.close();
340
+ reject(new Error('Access token not found in oauth response.'));
341
+ return;
342
+ }
343
+ // Retrieve user profile email using access token
344
+ let email = '';
345
+ try {
346
+ const profileResponse = await fetch(`${config.serverUrl}/api/auth/me`, {
347
+ headers: { 'Authorization': `Bearer ${accessToken}` }
348
+ });
349
+ if (profileResponse.ok) {
350
+ const profileData = await profileResponse.json();
351
+ email = profileData.email;
352
+ }
353
+ }
354
+ catch (profileErr) {
355
+ // Non-critical error, we can proceed without saving email or decode from JWT
356
+ }
357
+ res.writeHead(200, { 'Content-Type': 'text/html' });
358
+ res.end(getSuccessHtml(email));
359
+ server.close();
360
+ resolve({ token: accessToken, email });
361
+ }
362
+ else {
363
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
364
+ res.end('Not Found');
365
+ }
366
+ }
367
+ catch (err) {
368
+ res.writeHead(500, { 'Content-Type': 'text/html' });
369
+ res.end(getErrorHtml(err.message || 'Internal server error'));
370
+ server.close();
371
+ reject(err);
372
+ }
373
+ });
374
+ server.on('error', (err) => {
375
+ if (err.code === 'EADDRINUSE') {
376
+ reject(new Error(`Port ${port} is already in use. Please stop the application utilizing this port.`));
377
+ }
378
+ else {
379
+ reject(err);
380
+ }
381
+ });
382
+ server.listen(port, () => {
383
+ openBrowser(authorizeUrl);
384
+ });
385
+ });
386
+ }
387
+ const program = new Command();
388
+ program
389
+ .name('contextstore')
390
+ .description('Connect local projects and files to your AI persistent memory')
391
+ .version('1.0.0');
392
+ program
393
+ .command('login')
394
+ .description('Authenticate the CLI with your ContextStore account')
395
+ .action(async () => {
396
+ p.intro('ContextStore CLI Authentication');
397
+ const config = loadConfig();
398
+ const authMethod = await p.select({
399
+ message: 'Select authentication method:',
400
+ options: [
401
+ { value: 'browser', label: 'Browser-based Auto-Login (OAuth Flow) (Recommended)' },
402
+ { value: 'password', label: 'Email & Password (Automatic Token Retrieval)' },
403
+ { value: 'token', label: 'Paste JWT Token (Manual copy from Browser)' }
404
+ ]
405
+ });
406
+ if (p.isCancel(authMethod)) {
407
+ p.outro('Authentication cancelled.');
408
+ process.exit(0);
409
+ }
410
+ if (authMethod === 'browser') {
411
+ const s = p.spinner();
412
+ s.start('Waiting for browser authentication...');
413
+ try {
414
+ const result = await startBrowserLoginFlow(config);
415
+ saveConfig({ authToken: result.token, email: result.email });
416
+ s.stop('Authentication successful');
417
+ p.outro(`✓ Successfully authenticated as ${result.email}!`);
418
+ }
419
+ catch (error) {
420
+ s.stop('Authentication failed');
421
+ p.note(error.message || 'Error occurred during login', 'Login Error');
422
+ p.outro('Browser login failed. You can retry or use another login method.');
423
+ }
424
+ }
425
+ else if (authMethod === 'password') {
426
+ const email = await p.text({
427
+ message: 'Enter your email:',
428
+ placeholder: 'developer@example.com',
429
+ validate: (v) => (!v ? 'Email is required' : !v.includes('@') ? 'Invalid email' : undefined)
430
+ });
431
+ if (p.isCancel(email)) {
432
+ p.outro('Cancelled.');
433
+ process.exit(0);
434
+ }
435
+ const password = await p.password({
436
+ message: 'Enter your password:',
437
+ validate: (v) => (!v ? 'Password is required' : undefined)
438
+ });
439
+ if (p.isCancel(password)) {
440
+ p.outro('Cancelled.');
441
+ process.exit(0);
442
+ }
443
+ const s = p.spinner();
444
+ s.start('Authenticating with ContextStore server...');
445
+ try {
446
+ const response = await fetch(`${config.serverUrl}/api/auth/login`, {
447
+ method: 'POST',
448
+ headers: { 'Content-Type': 'application/json' },
449
+ body: JSON.stringify({ email: email.toLowerCase(), password })
450
+ });
451
+ const data = await response.json();
452
+ if (response.ok && data.token) {
453
+ saveConfig({ authToken: data.token, email: email.toLowerCase() });
454
+ s.stop('Authentication successful');
455
+ p.outro(`✓ Successfully authenticated as ${email}!`);
456
+ }
457
+ else {
458
+ s.stop('Authentication failed');
459
+ p.note(data.detail || 'Invalid email or password', 'Login Error');
460
+ p.outro('Please verify your credentials and try again.');
461
+ }
462
+ }
463
+ catch (error) {
464
+ s.stop('Authentication failed');
465
+ p.note(error.message || 'Could not connect to auth server', 'Network Error');
466
+ p.outro('Please make sure your backend server is running.');
467
+ }
468
+ }
469
+ else {
470
+ const token = await p.text({
471
+ message: 'Enter your ContextStore Auth Token:',
472
+ placeholder: 'eyJhbGciOi...',
473
+ validate: (value) => {
474
+ if (!value)
475
+ return 'Token is required';
476
+ if (value.length < 15)
477
+ return 'Invalid token length';
478
+ return;
479
+ }
480
+ });
481
+ if (p.isCancel(token)) {
482
+ p.outro('Authentication cancelled.');
483
+ process.exit(0);
484
+ }
485
+ const email = await p.text({
486
+ message: 'Confirm your account email:',
487
+ placeholder: 'name@domain.com',
488
+ validate: (value) => {
489
+ if (!value)
490
+ return 'Email is required';
491
+ if (!value.includes('@'))
492
+ return 'Invalid email address';
493
+ return;
494
+ }
495
+ });
496
+ if (p.isCancel(email)) {
497
+ p.outro('Authentication cancelled.');
498
+ process.exit(0);
499
+ }
500
+ saveConfig({ authToken: token, email: email.toLowerCase() });
501
+ p.outro('✓ Successfully authenticated! Token saved.');
502
+ }
503
+ });
504
+ program
505
+ .command('sync')
506
+ .description('Sync a workspace directory directly')
507
+ .argument('[path]', 'Path to the directory to sync', '.')
508
+ .action(async (dirPath) => {
509
+ const absolutePath = path.resolve(dirPath);
510
+ if (!fs.existsSync(absolutePath)) {
511
+ console.error(`Error: Path does not exist: ${absolutePath}`);
512
+ process.exit(1);
513
+ }
514
+ const config = loadConfig();
515
+ if (!config.authToken) {
516
+ console.error('Error: Not authenticated. Please run: npx contextstore-app login');
517
+ process.exit(1);
518
+ }
519
+ const files = walkWorkspace(absolutePath);
520
+ await performWorkspaceSync(absolutePath, files, config, 'Workspace');
521
+ });
522
+ program
523
+ .command('watch')
524
+ .description('Start file watcher daemon for incremental syncs')
525
+ .argument('[path]', 'Path to watch', '.')
526
+ .action(async (dirPath) => {
527
+ const absolutePath = path.resolve(dirPath);
528
+ if (!fs.existsSync(absolutePath)) {
529
+ console.error(`Error: Path does not exist: ${absolutePath}`);
530
+ process.exit(1);
531
+ }
532
+ const config = loadConfig();
533
+ if (!config.authToken) {
534
+ console.error('Error: Not authenticated. Please run: npx contextstore-app login');
535
+ process.exit(1);
536
+ }
537
+ p.intro(`Incremental Watcher Mode`);
538
+ startWatcher(absolutePath, config);
539
+ });
540
+ function cleanPath(input) {
541
+ let cleaned = input.trim();
542
+ if (cleaned.startsWith('"') && cleaned.endsWith('"')) {
543
+ cleaned = cleaned.substring(1, cleaned.length - 1);
544
+ }
545
+ if (cleaned.startsWith("'") && cleaned.endsWith("'")) {
546
+ cleaned = cleaned.substring(1, cleaned.length - 1);
547
+ }
548
+ return cleaned.trim();
549
+ }
550
+ // Main Interactive Entrypoint
551
+ async function interactiveFlow() {
552
+ p.intro('Welcome to ContextStore');
553
+ const config = loadConfig();
554
+ if (!config.authToken) {
555
+ p.note('Authentication required. Opening your browser to sign in to ContextStore...', 'Authentication Required');
556
+ const s = p.spinner();
557
+ s.start('Waiting for browser authentication...');
558
+ try {
559
+ const result = await startBrowserLoginFlow(config);
560
+ saveConfig({ authToken: result.token, email: result.email });
561
+ config.authToken = result.token;
562
+ config.email = result.email;
563
+ s.stop('Authentication successful');
564
+ p.note(`✓ Successfully authenticated as ${result.email}!`, 'Success');
565
+ }
566
+ catch (error) {
567
+ s.stop('Authentication failed');
568
+ p.note(error.message || 'Error occurred during login', 'Login Error');
569
+ p.outro('Login failed. Please verify the server is running and try again.');
570
+ process.exit(0);
571
+ }
572
+ }
573
+ const syncType = await p.select({
574
+ message: 'What do you want to sync?',
575
+ options: [
576
+ { value: 'workspace', label: 'Workspace (Full project folders, auto-detected)', hint: 'recommended' },
577
+ { value: 'custom', label: 'Custom path (Type any local folder path)' }
578
+ ]
579
+ });
580
+ if (p.isCancel(syncType)) {
581
+ p.outro('Cancelled.');
582
+ process.exit(0);
583
+ }
584
+ let selectedPath = '';
585
+ if (syncType === 'workspace') {
586
+ const s = p.spinner();
587
+ s.start('Detecting local projects...');
588
+ const detected = await detectProjects();
589
+ s.stop('Projects detected');
590
+ const options = detected.map(p => ({
591
+ value: p.path,
592
+ label: `${p.name.padEnd(20)} ${p.path.padEnd(35)}`,
593
+ hint: `[${p.type}]`
594
+ }));
595
+ options.push({
596
+ value: 'custom',
597
+ label: 'Enter custom path...',
598
+ hint: ''
599
+ });
600
+ const projectSelect = await p.select({
601
+ message: 'Select a project:',
602
+ options
603
+ });
604
+ if (p.isCancel(projectSelect)) {
605
+ p.outro('Cancelled.');
606
+ process.exit(0);
607
+ }
608
+ if (projectSelect === 'custom') {
609
+ const customPath = await p.text({
610
+ message: 'Enter absolute folder path:',
611
+ validate: (v) => {
612
+ const cleaned = cleanPath(v);
613
+ if (!cleaned)
614
+ return 'Path is required';
615
+ if (!fs.existsSync(cleaned))
616
+ return 'Directory does not exist';
617
+ return;
618
+ }
619
+ });
620
+ if (p.isCancel(customPath)) {
621
+ p.outro('Cancelled.');
622
+ process.exit(0);
623
+ }
624
+ selectedPath = path.resolve(cleanPath(customPath));
625
+ }
626
+ else {
627
+ selectedPath = projectSelect;
628
+ }
629
+ }
630
+ else if (syncType === 'custom') {
631
+ const customPath = await p.text({
632
+ message: 'Enter folder path:',
633
+ validate: (v) => {
634
+ const cleaned = cleanPath(v);
635
+ if (!cleaned)
636
+ return 'Path is required';
637
+ if (!fs.existsSync(cleaned))
638
+ return 'Directory does not exist';
639
+ return;
640
+ }
641
+ });
642
+ if (p.isCancel(customPath)) {
643
+ p.outro('Cancelled.');
644
+ process.exit(0);
645
+ }
646
+ selectedPath = path.resolve(cleanPath(customPath));
647
+ }
648
+ // Ask for sync mode (All files vs Selective files)
649
+ const syncMode = await p.select({
650
+ message: 'How do you want to sync this folder?',
651
+ options: [
652
+ { value: 'all', label: 'Sync all files (recommended)' },
653
+ { value: 'selective', label: 'Select specific files to sync' }
654
+ ]
655
+ });
656
+ if (p.isCancel(syncMode)) {
657
+ p.outro('Cancelled.');
658
+ process.exit(0);
659
+ }
660
+ let filesToSync = [];
661
+ if (syncMode === 'selective') {
662
+ const s = p.spinner();
663
+ s.start('Scanning workspace files for selection...');
664
+ const allFiles = walkWorkspace(selectedPath);
665
+ s.stop(`Done. Found ${allFiles.length} indexable files.`);
666
+ if (allFiles.length === 0) {
667
+ p.note('No indexable files found in this directory based on ignore rules.', 'Scan Info');
668
+ p.outro('Goodbye!');
669
+ process.exit(0);
670
+ }
671
+ const fileOptions = allFiles.map(file => ({
672
+ value: file,
673
+ label: path.relative(selectedPath, file).replace(/\\/g, '/')
674
+ }));
675
+ const fileSelection = await p.multiselect({
676
+ message: 'Select files to sync (Space to select, Enter to confirm):',
677
+ options: fileOptions,
678
+ required: true
679
+ });
680
+ if (p.isCancel(fileSelection)) {
681
+ p.outro('Cancelled.');
682
+ process.exit(0);
683
+ }
684
+ filesToSync = fileSelection;
685
+ }
686
+ else {
687
+ const s = p.spinner();
688
+ s.start('Walking directories & filtering ignored paths...');
689
+ filesToSync = walkWorkspace(selectedPath);
690
+ s.stop(`Done. Found ${filesToSync.length} files to index.`);
691
+ }
692
+ // Perform indexing & syncing
693
+ await performWorkspaceSync(selectedPath, filesToSync, config, syncMode === 'selective' ? 'Files' : 'Workspace');
694
+ // Ask to watch directory
695
+ const watchConfirm = await p.confirm({
696
+ message: 'Would you like to watch this directory for file changes in the background?',
697
+ active: 'Yes (watch)',
698
+ inactive: 'No (exit)'
699
+ });
700
+ if (p.isCancel(watchConfirm) || !watchConfirm) {
701
+ p.outro('✓ Sync complete. Goodbye!');
702
+ process.exit(0);
703
+ }
704
+ p.note(`Watching for changes... Pres SIGINT (Ctrl+C) to stop.`, 'Active Watcher');
705
+ startWatcher(selectedPath, config);
706
+ }
707
+ async function performWorkspaceSync(workspacePath, files, config, syncTypeName = 'Workspace') {
708
+ const startTime = Date.now();
709
+ const projectType = detectProjectType(workspacePath);
710
+ p.note(`Project Path: ${workspacePath}\nProject Type: ${projectType}`, `Syncing ${syncTypeName}`);
711
+ if (files.length === 0) {
712
+ p.note('No files to sync.', 'Sync Info');
713
+ return;
714
+ }
715
+ // 1. Print syncing header
716
+ console.log(' \x1b[36m◆\x1b[0m Syncing workspace files...');
717
+ // 2. Hide terminal cursor
718
+ process.stdout.write('\x1b[?25l');
719
+ let successCount = 0;
720
+ let failedCount = 0;
721
+ const errors = [];
722
+ for (let index = 0; index < files.length; index += 1) {
723
+ const file = files[index];
724
+ const percentage = Math.round(((index + 1) / files.length) * 100);
725
+ const relativeName = path.relative(workspacePath, file).replace(/\\/g, '/');
726
+ // Render beautiful colored progress bar
727
+ const barWidth = 20;
728
+ const filledWidth = Math.round((percentage / 100) * barWidth);
729
+ const emptyWidth = barWidth - filledWidth;
730
+ const filledBar = `\x1b[32m${'▰'.repeat(filledWidth)}\x1b[0m`;
731
+ const emptyBar = `\x1b[90m${'▱'.repeat(emptyWidth)}\x1b[0m`;
732
+ const percentText = `\x1b[1;32m${percentage}%\x1b[0m`;
733
+ const countText = `\x1b[36m${index + 1}/${files.length} files\x1b[0m`;
734
+ const fileText = `\x1b[90mSynced ${relativeName}\x1b[0m`;
735
+ // Clear line and write progress in-place
736
+ process.stdout.write(`\r\x1b[K \x1b[36m│\x1b[0m ${filledBar}${emptyBar} ${percentText} | ${countText} | ${fileText}`);
737
+ const result = await syncFile(file, workspacePath, config);
738
+ if (result.status === 'synced') {
739
+ successCount += 1;
740
+ }
741
+ else {
742
+ failedCount += 1;
743
+ if (result.error) {
744
+ errors.push(`${relativeName}: ${result.error}`);
745
+ }
746
+ }
747
+ }
748
+ // 3. Restore terminal cursor and clear line
749
+ process.stdout.write('\x1b[?25h\r\x1b[K\n');
750
+ const duration = ((Date.now() - startTime) / 1000).toFixed(1);
751
+ if (failedCount === 0) {
752
+ console.log(' \x1b[32m✓\x1b[0m Sync completed successfully!');
753
+ }
754
+ else {
755
+ console.log(' \x1b[33m⚠\x1b[0m Sync completed with some failures.');
756
+ }
757
+ p.note(`Project : ${path.basename(workspacePath)} [${projectType}]\nFiles : ${successCount} indexed (${failedCount} failed)\nSync Time : ${duration}s`, 'Sync Status');
758
+ // Render synced files tree structure
759
+ try {
760
+ const relativeFiles = files.map(f => path.relative(workspacePath, f));
761
+ const treeRoot = buildTree(workspacePath, relativeFiles);
762
+ const { lines: treeLines, truncated } = renderTree(treeRoot);
763
+ let treeText = treeLines.join('\n');
764
+ if (truncated) {
765
+ treeText += `\n\n\x1b[90m... and ${files.length - treeLines.length + 1} more files collapsed (showing top structure)\x1b[0m`;
766
+ }
767
+ p.note(treeText, 'Synced Workspace Structure');
768
+ }
769
+ catch (err) {
770
+ // Fail-safe to ensure CLI doesn't crash if tree builder fails
771
+ }
772
+ if (failedCount > 0) {
773
+ const sampleErrors = errors.slice(0, 5);
774
+ let errorSummary = `Some files failed to sync. Top error details:\n` + sampleErrors.map(e => ` \x1b[31m•\x1b[0m ${e}`).join('\n');
775
+ if (errors.length > 5) {
776
+ errorSummary += `\n ... and ${errors.length - 5} more errors.`;
777
+ }
778
+ errorSummary += `\n\n\x1b[33mTip:\x1b[0m Make sure your backend server is running and your token is valid. Check network connectivity.`;
779
+ p.note(errorSummary, 'Sync Failure Details');
780
+ }
781
+ // Offer to generate/update CLAUDE.md for Claude Code integration
782
+ const claudeMdPath = path.join(workspacePath, 'CLAUDE.md');
783
+ const hasClaudeMd = fs.existsSync(claudeMdPath);
784
+ const setupClaude = await p.confirm({
785
+ message: hasClaudeMd
786
+ ? 'A CLAUDE.md file already exists. Would you like to append ContextStore instructions?'
787
+ : 'Would you like to auto-generate a CLAUDE.md instructions file in this workspace to link with Claude Code?',
788
+ active: 'Yes (setup)',
789
+ inactive: 'No'
790
+ });
791
+ if (!p.isCancel(setupClaude) && setupClaude) {
792
+ const instructionText = `# contextstore — Codex Memory Instructions\n\nYou are connected to **contextstore** persistent memory via the **hydradb-plus** MCP server.\n\n## At the START of every new conversation or mid-session context reload\n1. Call \`smart_checkin(user_intent='<brief description of what you want to do>')\` immediately.\n2. Log every message turn using \`log_conversation_turn()\`.\n3. If \`pending_syncs\` is returned from \`smart_checkin\`, read each raw file outline, extract codebase memories using \`memory_store(memory_type='codebase', ...)\` and call \`mark_sync_done(sync_id)\` for each processed item silently before answering the user.\n`;
793
+ try {
794
+ if (hasClaudeMd) {
795
+ // Append or replace if already present
796
+ const currentContent = fs.readFileSync(claudeMdPath, 'utf-8');
797
+ if (!currentContent.includes('contextstore')) {
798
+ fs.writeFileSync(claudeMdPath, currentContent + '\n\n' + instructionText, 'utf-8');
799
+ p.note(`✓ Appended ContextStore instructions to existing CLAUDE.md`, 'Integration Setup');
800
+ }
801
+ else {
802
+ p.note(`✓ CLAUDE.md already configured for ContextStore.`, 'Integration Setup');
803
+ }
804
+ }
805
+ else {
806
+ fs.writeFileSync(claudeMdPath, instructionText, 'utf-8');
807
+ p.note(`✓ Successfully created CLAUDE.md in your workspace root.`, 'Integration Setup');
808
+ }
809
+ }
810
+ catch (err) {
811
+ p.note(`Failed to write CLAUDE.md: ${err.message}`, 'Setup Error');
812
+ }
813
+ }
814
+ saveConfig({ activeWorkspace: workspacePath });
815
+ }
816
+ function buildTree(basePath, relativePaths) {
817
+ const root = { name: path.basename(basePath), isFile: false, children: new Map() };
818
+ for (const relPath of relativePaths) {
819
+ const parts = relPath.split(/[/\\]/);
820
+ let current = root;
821
+ for (let i = 0; i < parts.length; i++) {
822
+ const part = parts[i];
823
+ const isFile = i === parts.length - 1;
824
+ if (!current.children.has(part)) {
825
+ current.children.set(part, {
826
+ name: part,
827
+ isFile,
828
+ children: new Map()
829
+ });
830
+ }
831
+ current = current.children.get(part);
832
+ }
833
+ }
834
+ return root;
835
+ }
836
+ function renderTree(node, prefix = '', isLast = true, depth = 0, lines = [], maxLines = 20) {
837
+ if (lines.length >= maxLines) {
838
+ return { lines, truncated: true };
839
+ }
840
+ if (depth > 0) {
841
+ const connector = isLast ? '└── ' : '├── ';
842
+ const nameColor = node.isFile ? `\x1b[37m${node.name}\x1b[0m` : `\x1b[1;34m${node.name}/\x1b[0m`;
843
+ lines.push(`${prefix}${connector}${nameColor}`);
844
+ }
845
+ else {
846
+ lines.push(`\x1b[1;36m${node.name}\x1b[0m`);
847
+ }
848
+ const childPrefix = depth === 0 ? '' : prefix + (isLast ? ' ' : '│ ');
849
+ const sortedChildren = Array.from(node.children.values()).sort((a, b) => {
850
+ if (a.isFile !== b.isFile) {
851
+ return a.isFile ? 1 : -1;
852
+ }
853
+ return a.name.localeCompare(b.name);
854
+ });
855
+ for (let i = 0; i < sortedChildren.length; i++) {
856
+ if (lines.length >= maxLines) {
857
+ return { lines, truncated: true };
858
+ }
859
+ const child = sortedChildren[i];
860
+ const isLastChild = i === sortedChildren.length - 1;
861
+ const res = renderTree(child, childPrefix, isLastChild, depth + 1, lines, maxLines);
862
+ if (res.truncated) {
863
+ return res;
864
+ }
865
+ }
866
+ return { lines, truncated: false };
867
+ }
868
+ // Parse input arguments
869
+ if (process.argv.length > 2) {
870
+ program.parse(process.argv);
871
+ }
872
+ else {
873
+ void interactiveFlow();
874
+ }