aline-ai 0.2.5__py3-none-any.whl → 0.3.0__py3-none-any.whl

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 (45) hide show
  1. {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/METADATA +3 -1
  2. aline_ai-0.3.0.dist-info/RECORD +41 -0
  3. aline_ai-0.3.0.dist-info/entry_points.txt +3 -0
  4. realign/__init__.py +32 -1
  5. realign/cli.py +203 -19
  6. realign/commands/__init__.py +2 -2
  7. realign/commands/clean.py +149 -0
  8. realign/commands/config.py +1 -1
  9. realign/commands/export_shares.py +1785 -0
  10. realign/commands/hide.py +112 -24
  11. realign/commands/import_history.py +873 -0
  12. realign/commands/init.py +104 -217
  13. realign/commands/mirror.py +131 -0
  14. realign/commands/pull.py +101 -0
  15. realign/commands/push.py +155 -245
  16. realign/commands/review.py +216 -54
  17. realign/commands/session_utils.py +139 -4
  18. realign/commands/share.py +965 -0
  19. realign/commands/status.py +559 -0
  20. realign/commands/sync.py +91 -0
  21. realign/commands/undo.py +423 -0
  22. realign/commands/watcher.py +805 -0
  23. realign/config.py +21 -10
  24. realign/file_lock.py +3 -1
  25. realign/hash_registry.py +310 -0
  26. realign/hooks.py +368 -384
  27. realign/logging_config.py +2 -2
  28. realign/mcp_server.py +263 -549
  29. realign/mcp_watcher.py +999 -142
  30. realign/mirror_utils.py +322 -0
  31. realign/prompts/__init__.py +21 -0
  32. realign/prompts/presets.py +238 -0
  33. realign/redactor.py +168 -16
  34. realign/tracker/__init__.py +9 -0
  35. realign/tracker/git_tracker.py +1123 -0
  36. realign/watcher_daemon.py +115 -0
  37. aline_ai-0.2.5.dist-info/RECORD +0 -28
  38. aline_ai-0.2.5.dist-info/entry_points.txt +0 -5
  39. realign/commands/auto_commit.py +0 -231
  40. realign/commands/commit.py +0 -379
  41. realign/commands/search.py +0 -449
  42. realign/commands/show.py +0 -416
  43. {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/WHEEL +0 -0
  44. {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/licenses/LICENSE +0 -0
  45. {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,965 @@
1
+ """Share commands - Manage session sharing and collaboration."""
2
+
3
+ import os
4
+ import webbrowser
5
+ import getpass
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import yaml
11
+
12
+ from ..tracker.git_tracker import ReAlignGitTracker
13
+ from ..logging_config import setup_logger
14
+
15
+ logger = setup_logger('realign.commands.share', 'share.log')
16
+
17
+
18
+ def share_configure_command(
19
+ remote: str,
20
+ token: Optional[str] = None,
21
+ repo_root: Optional[Path] = None
22
+ ) -> int:
23
+ """
24
+ Manually configure remote repository for sharing.
25
+
26
+ Args:
27
+ remote: Remote repository (e.g., user/repo or full URL)
28
+ token: GitHub access token (optional)
29
+ repo_root: Path to repository root (uses cwd if not provided)
30
+
31
+ Returns:
32
+ Exit code (0 for success, 1 for error)
33
+ """
34
+ # Get project root
35
+ if repo_root is None:
36
+ repo_root = Path(os.getcwd()).resolve()
37
+
38
+ # Initialize tracker
39
+ tracker = ReAlignGitTracker(repo_root)
40
+
41
+ # Check if repository is initialized
42
+ if not tracker.is_initialized():
43
+ print("❌ Repository not initialized")
44
+ print("Run 'aline init' first")
45
+ return 1
46
+
47
+ # Parse remote URL
48
+ remote_url = _parse_remote_url(remote)
49
+
50
+ if not remote_url:
51
+ print("❌ Invalid remote format")
52
+ print("\nExpected formats:")
53
+ print(" user/repo")
54
+ print(" https://github.com/user/repo.git")
55
+ return 1
56
+
57
+ # Store token in git credentials if provided
58
+ if token:
59
+ _store_git_credentials(remote_url, token)
60
+
61
+ # Configure remote
62
+ print(f"Configuring remote: {remote_url}")
63
+
64
+ success = tracker.setup_remote(remote_url)
65
+
66
+ if not success:
67
+ print("❌ Failed to configure remote")
68
+ return 1
69
+
70
+ print("✓ Remote configured successfully")
71
+
72
+ # Try initial push
73
+ print("\nAttempting initial push...")
74
+
75
+ push_success = tracker.safe_push()
76
+
77
+ if push_success:
78
+ print("✓ Successfully pushed to remote")
79
+ else:
80
+ print("⚠️ Initial push failed")
81
+ print("\nPossible issues:")
82
+ print(" - Repository doesn't exist (create it on GitHub first)")
83
+ print(" - No access permissions (check GitHub access token)")
84
+ print(" - Network issues")
85
+ print("\nYou can try pushing later with: aline push")
86
+
87
+ print(f"\nRemote: {remote_url}")
88
+
89
+ # Update config
90
+ _update_sharing_config(repo_root, remote_url, enabled=True)
91
+
92
+ print("\nNext steps:")
93
+ print(" - Push sessions: aline push")
94
+ print(" - Invite teammates: aline share invite <email>")
95
+
96
+ return 0
97
+
98
+
99
+ def share_status_command(repo_root: Optional[Path] = None) -> int:
100
+ """
101
+ Show current sharing configuration.
102
+
103
+ Args:
104
+ repo_root: Path to repository root (uses cwd if not provided)
105
+
106
+ Returns:
107
+ Exit code (0 for success, 1 for error)
108
+ """
109
+ # Get project root
110
+ if repo_root is None:
111
+ repo_root = Path(os.getcwd()).resolve()
112
+
113
+ # Initialize tracker
114
+ tracker = ReAlignGitTracker(repo_root)
115
+
116
+ if not tracker.is_initialized():
117
+ print("Repository not initialized")
118
+ return 1
119
+
120
+ # Check if remote is configured
121
+ remote_url = tracker.get_remote_url()
122
+
123
+ if not remote_url:
124
+ print("Sharing: Disabled")
125
+ print("\nTo enable sharing:")
126
+ print(" aline init --share (browser-based setup)")
127
+ print(" aline share configure (manual configuration)")
128
+ return 0
129
+
130
+ print("Sharing: Enabled ✓")
131
+ print(f"Remote: {remote_url}")
132
+
133
+ # Get unpushed commits
134
+ unpushed = tracker.get_unpushed_commits()
135
+ print(f"Unpushed commits: {len(unpushed)}")
136
+
137
+ # Load config to show additional details
138
+ config = tracker.config.get('sharing', {})
139
+
140
+ if config.get('owner'):
141
+ print(f"Owner: {config['owner']}")
142
+
143
+ if config.get('created_at'):
144
+ print(f"Created: {config['created_at']}")
145
+
146
+ return 0
147
+
148
+
149
+ def share_invite_command(
150
+ email: Optional[str] = None,
151
+ repo_root: Optional[Path] = None
152
+ ) -> int:
153
+ """
154
+ Invite collaborator to shared repository.
155
+
156
+ Opens GitHub collaboration settings page in browser.
157
+
158
+ Args:
159
+ email: Email address to invite (optional, for display only)
160
+ repo_root: Path to repository root (uses cwd if not provided)
161
+
162
+ Returns:
163
+ Exit code (0 for success, 1 for error)
164
+ """
165
+ # Get project root
166
+ if repo_root is None:
167
+ repo_root = Path(os.getcwd()).resolve()
168
+
169
+ # Initialize tracker
170
+ tracker = ReAlignGitTracker(repo_root)
171
+
172
+ if not tracker.has_remote():
173
+ print("❌ No remote configured")
174
+ print("Run 'aline share configure' first")
175
+ return 1
176
+
177
+ # Get remote URL
178
+ remote_url = tracker.get_remote_url()
179
+
180
+ # Parse GitHub repo from URL
181
+ repo_path = _extract_github_repo(remote_url)
182
+
183
+ if not repo_path:
184
+ print("❌ Not a GitHub repository")
185
+ print(f"Remote: {remote_url}")
186
+ return 1
187
+
188
+ # Construct GitHub collaborators URL
189
+ github_url = f"https://github.com/{repo_path}/settings/access"
190
+
191
+ print("Opening GitHub collaboration page...")
192
+ print(f"Repository: {repo_path}")
193
+
194
+ if email:
195
+ print(f"Inviting: {email}")
196
+
197
+ # Open browser
198
+ webbrowser.open(github_url)
199
+
200
+ print("\nOnce invited, they can join with:")
201
+ print(f" aline init --join {repo_path}")
202
+
203
+ return 0
204
+
205
+
206
+ def share_link_command(repo_root: Optional[Path] = None) -> int:
207
+ """
208
+ Get shareable link for teammates to join.
209
+
210
+ Args:
211
+ repo_root: Path to repository root (uses cwd if not provided)
212
+
213
+ Returns:
214
+ Exit code (0 for success, 1 for error)
215
+ """
216
+ # Get project root
217
+ if repo_root is None:
218
+ repo_root = Path(os.getcwd()).resolve()
219
+
220
+ # Initialize tracker
221
+ tracker = ReAlignGitTracker(repo_root)
222
+
223
+ if not tracker.has_remote():
224
+ print("❌ No remote configured")
225
+ print("Run 'aline share configure' first")
226
+ return 1
227
+
228
+ # Get remote URL
229
+ remote_url = tracker.get_remote_url()
230
+
231
+ # Parse GitHub repo from URL
232
+ repo_path = _extract_github_repo(remote_url)
233
+
234
+ if not repo_path:
235
+ print(f"Remote URL: {remote_url}")
236
+ print("\nShare this URL with teammates")
237
+ return 0
238
+
239
+ print("Share with teammates:")
240
+ print()
241
+ print(f"Repository: https://github.com/{repo_path}")
242
+ print()
243
+ print("Join command:")
244
+ print(f" aline init --join {repo_path}")
245
+
246
+ return 0
247
+
248
+
249
+ def share_disable_command(repo_root: Optional[Path] = None) -> int:
250
+ """
251
+ Disable sharing (keeps history intact).
252
+
253
+ Args:
254
+ repo_root: Path to repository root (uses cwd if not provided)
255
+
256
+ Returns:
257
+ Exit code (0 for success, 1 for error)
258
+ """
259
+ # Get project root
260
+ if repo_root is None:
261
+ repo_root = Path(os.getcwd()).resolve()
262
+
263
+ # Initialize tracker
264
+ tracker = ReAlignGitTracker(repo_root)
265
+
266
+ if not tracker.has_remote():
267
+ print("Sharing is already disabled")
268
+ return 0
269
+
270
+ remote_url = tracker.get_remote_url()
271
+
272
+ confirm = input(f"Remove remote: {remote_url}? [y/N]: ").strip().lower()
273
+ if confirm != 'y':
274
+ print("Cancelled")
275
+ return 0
276
+
277
+ # Remove remote
278
+ try:
279
+ tracker._run_git(
280
+ ["remote", "remove", "origin"],
281
+ cwd=tracker.realign_dir,
282
+ check=True
283
+ )
284
+
285
+ print("✓ Remote removed")
286
+ print("Local history preserved")
287
+
288
+ # Update config
289
+ _update_sharing_config(repo_root, None, enabled=False)
290
+
291
+ return 0
292
+
293
+ except Exception as e:
294
+ logger.error(f"Failed to remove remote: {e}")
295
+ print("❌ Failed to remove remote")
296
+ return 1
297
+
298
+
299
+ # Helper functions
300
+
301
+ def _parse_remote_url(remote: str) -> Optional[str]:
302
+ """
303
+ Parse remote URL from various formats.
304
+
305
+ Supports:
306
+ - user/repo -> https://github.com/user/repo.git
307
+ - https://github.com/user/repo.git (unchanged)
308
+ - git@github.com:user/repo.git (unchanged)
309
+ """
310
+ remote = remote.strip()
311
+
312
+ # Already a full URL
313
+ if remote.startswith('http://') or remote.startswith('https://') or remote.startswith('git@'):
314
+ return remote
315
+
316
+ # Short format: user/repo
317
+ if '/' in remote and not remote.startswith('/'):
318
+ parts = remote.split('/')
319
+ if len(parts) == 2:
320
+ user, repo = parts
321
+ # Remove .git suffix if present
322
+ repo = repo.replace('.git', '')
323
+ return f"https://github.com/{user}/{repo}.git"
324
+
325
+ return None
326
+
327
+
328
+ def _extract_github_repo(url: str) -> Optional[str]:
329
+ """
330
+ Extract GitHub repo path (user/repo) from URL.
331
+
332
+ Examples:
333
+ https://github.com/alice/myproject.git -> alice/myproject
334
+ git@github.com:alice/myproject.git -> alice/myproject
335
+ """
336
+ url = url.strip()
337
+
338
+ # HTTPS URL
339
+ if 'github.com/' in url:
340
+ parts = url.split('github.com/')
341
+ if len(parts) == 2:
342
+ repo_path = parts[1]
343
+ # Remove .git suffix
344
+ repo_path = repo_path.replace('.git', '')
345
+ return repo_path
346
+
347
+ # SSH URL
348
+ if 'github.com:' in url:
349
+ parts = url.split('github.com:')
350
+ if len(parts) == 2:
351
+ repo_path = parts[1]
352
+ # Remove .git suffix
353
+ repo_path = repo_path.replace('.git', '')
354
+ return repo_path
355
+
356
+ return None
357
+
358
+
359
+ def _get_github_username(token: str) -> Optional[str]:
360
+ """
361
+ Get GitHub username from a personal access token using GitHub API.
362
+
363
+ Args:
364
+ token: GitHub personal access token
365
+
366
+ Returns:
367
+ GitHub username or None if failed
368
+ """
369
+ try:
370
+ import urllib.request
371
+ import json
372
+
373
+ req = urllib.request.Request(
374
+ "https://api.github.com/user",
375
+ headers={"Authorization": f"token {token}"}
376
+ )
377
+
378
+ with urllib.request.urlopen(req, timeout=5) as response:
379
+ data = json.loads(response.read().decode())
380
+ username = data.get("login")
381
+ if username:
382
+ logger.info(f"Retrieved GitHub username: {username}")
383
+ return username
384
+ except Exception as e:
385
+ logger.warning(f"Failed to get GitHub username: {e}")
386
+ return None
387
+
388
+
389
+ def _store_git_credentials(url: str, token: str):
390
+ """
391
+ Store GitHub token in git credential helper.
392
+
393
+ This allows git to authenticate without prompting.
394
+ """
395
+ try:
396
+ import subprocess
397
+
398
+ # Configure credential helper
399
+ subprocess.run(
400
+ ["git", "config", "--global", "credential.helper", "store"],
401
+ check=False
402
+ )
403
+
404
+ # Extract hostname from URL
405
+ if 'github.com' in url:
406
+ hostname = 'github.com'
407
+ else:
408
+ # Extract from URL
409
+ from urllib.parse import urlparse
410
+ parsed = urlparse(url)
411
+ hostname = parsed.hostname or 'github.com'
412
+
413
+ # Store credentials
414
+ # Format: https://<token>@github.com
415
+ cred_url = f"https://{token}@{hostname}"
416
+
417
+ # Write to credential store
418
+ cred_file = Path.home() / '.git-credentials'
419
+
420
+ # Read existing credentials
421
+ existing_creds = []
422
+ if cred_file.exists():
423
+ existing_creds = cred_file.read_text().strip().split('\n')
424
+
425
+ # Remove existing credentials for this host
426
+ existing_creds = [c for c in existing_creds if hostname not in c]
427
+
428
+ # Add new credentials
429
+ existing_creds.append(cred_url)
430
+
431
+ # Write back
432
+ cred_file.write_text('\n'.join(existing_creds) + '\n')
433
+ cred_file.chmod(0o600) # Secure permissions
434
+
435
+ logger.info("Stored git credentials")
436
+
437
+ except Exception as e:
438
+ logger.warning(f"Failed to store credentials: {e}")
439
+ # Non-fatal - user can authenticate manually
440
+
441
+
442
+ def _update_sharing_config(project_root: Path, remote_url: Optional[str], enabled: bool, member_name: Optional[str] = None):
443
+ """
444
+ Update config.yaml with sharing configuration.
445
+
446
+ Args:
447
+ project_root: Path to project root
448
+ remote_url: GitHub remote URL
449
+ enabled: Whether sharing is enabled
450
+ member_name: GitHub username of the member (for joined repositories)
451
+ """
452
+ from realign import get_realign_dir
453
+ realign_dir = get_realign_dir(project_root)
454
+ config_path = realign_dir / "config.yaml"
455
+
456
+ # Load existing config
457
+ config = {}
458
+ if config_path.exists():
459
+ with open(config_path, 'r', encoding='utf-8') as f:
460
+ config = yaml.safe_load(f) or {}
461
+
462
+ # Update sharing section
463
+ if 'sharing' not in config:
464
+ config['sharing'] = {}
465
+
466
+ config['sharing']['enabled'] = enabled
467
+
468
+ if remote_url:
469
+ config['sharing']['remote_url'] = remote_url
470
+
471
+ # Extract owner from URL
472
+ repo_path = _extract_github_repo(remote_url)
473
+ if repo_path:
474
+ owner = repo_path.split('/')[0]
475
+ config['sharing']['owner'] = owner
476
+
477
+ # Set created_at if not already set
478
+ if 'created_at' not in config['sharing']:
479
+ config['sharing']['created_at'] = datetime.now().isoformat()
480
+
481
+ # Store member name if provided (for joined repositories)
482
+ if member_name:
483
+ config['sharing']['member_name'] = member_name
484
+ config['sharing']['member_branch'] = f"{member_name}/master"
485
+
486
+ # Save config
487
+ with open(config_path, 'w', encoding='utf-8') as f:
488
+ yaml.safe_dump(config, f, default_flow_style=False, sort_keys=False)
489
+
490
+ logger.info(f"Updated sharing config: enabled={enabled}, member={member_name}")
491
+
492
+
493
+ def init_share_flow(repo_root: Optional[Path] = None) -> int:
494
+ """
495
+ Interactive flow for setting up a new shared repository.
496
+
497
+ This is called by `aline init --share`.
498
+
499
+ Args:
500
+ repo_root: Path to repository root (uses cwd if not provided)
501
+
502
+ Returns:
503
+ Exit code (0 for success, 1 for error)
504
+ """
505
+ # Get project root
506
+ if repo_root is None:
507
+ repo_root = Path(os.getcwd()).resolve()
508
+
509
+ print("╭─────────────────────────────────────────╮")
510
+ print("│ ReAlign Sharing Setup │")
511
+ print("╰─────────────────────────────────────────╯")
512
+ print()
513
+
514
+ # Initialize ReAlign if not already done
515
+ from .init import init_repository
516
+ tracker = ReAlignGitTracker(repo_root)
517
+
518
+ if not tracker.is_initialized():
519
+ print("Initializing ReAlign...")
520
+ result = init_repository(repo_path=str(repo_root))
521
+ if not result["success"]:
522
+ print("❌ Failed to initialize ReAlign")
523
+ return 1
524
+ print("✓ ReAlign initialized")
525
+ print()
526
+
527
+ # Check if remote already configured
528
+ if tracker.has_remote():
529
+ remote_url = tracker.get_remote_url()
530
+ print(f"⚠️ Remote already configured: {remote_url}")
531
+ print()
532
+ confirm = input("Reconfigure? [y/N]: ").strip().lower()
533
+ if confirm != 'y':
534
+ print("Cancelled")
535
+ return 0
536
+ print()
537
+
538
+ # Step 1: Get repository name
539
+ print("[1/3] Repository Setup")
540
+ print("─────────────────────")
541
+ print()
542
+ print("Choose a repository name for your team's sessions.")
543
+ print("This will be created as a private repository on GitHub.")
544
+ print()
545
+
546
+ # Suggest default name
547
+ suggested_name = repo_root.name + "-realign-sessions"
548
+ repo_name = input(f"Repository name [{suggested_name}]: ").strip()
549
+ if not repo_name:
550
+ repo_name = suggested_name
551
+
552
+ print()
553
+
554
+ # Step 2: Get GitHub username
555
+ print("[2/3] GitHub Account")
556
+ print("────────────────────")
557
+ print()
558
+ github_user = input("GitHub username: ").strip()
559
+
560
+ if not github_user:
561
+ print("❌ GitHub username required")
562
+ return 1
563
+
564
+ print()
565
+
566
+ # Step 3: Get GitHub token
567
+ print("[3/3] Authentication")
568
+ print("────────────────────")
569
+ print()
570
+ print("Create a GitHub Personal Access Token:")
571
+ print(" 1. Go to: https://github.com/settings/tokens/new")
572
+ print(" 2. Name: 'ReAlign Sharing'")
573
+ print(" 3. Scopes: Select 'repo' (full control of private repositories)")
574
+ print(" 4. Generate token and paste below")
575
+ print()
576
+
577
+ # Open browser to token creation page
578
+ open_browser = input("Open token creation page in browser? [Y/n]: ").strip().lower()
579
+ if open_browser != 'n':
580
+ token_url = "https://github.com/settings/tokens/new?description=ReAlign%20Sharing&scopes=repo"
581
+ webbrowser.open(token_url)
582
+ print("✓ Opened in browser")
583
+ print()
584
+
585
+ # Get token securely
586
+ token = getpass.getpass("GitHub Personal Access Token: ").strip()
587
+
588
+ if not token:
589
+ print("❌ Token required")
590
+ return 1
591
+
592
+ print()
593
+ print("Setting up repository...")
594
+ print()
595
+
596
+ # Construct repository path and URL
597
+ repo_path = f"{github_user}/{repo_name}"
598
+ remote_url = f"https://github.com/{repo_path}.git"
599
+
600
+ # Create repository using GitHub API
601
+ try:
602
+ import subprocess
603
+ import json
604
+
605
+ # Use gh CLI if available, otherwise use GitHub API directly
606
+ gh_available = False
607
+ try:
608
+ gh_check = subprocess.run(
609
+ ["gh", "--version"],
610
+ capture_output=True,
611
+ check=False
612
+ )
613
+ gh_available = (gh_check.returncode == 0)
614
+ except FileNotFoundError:
615
+ gh_available = False
616
+
617
+ if gh_available:
618
+ # Use gh CLI
619
+ print("Creating repository using GitHub CLI...")
620
+ result = subprocess.run(
621
+ [
622
+ "gh", "repo", "create", repo_path,
623
+ "--private",
624
+ "--description", "ReAlign AI session history"
625
+ ],
626
+ env={**os.environ, "GH_TOKEN": token},
627
+ capture_output=True,
628
+ text=True,
629
+ check=False
630
+ )
631
+
632
+ if result.returncode != 0:
633
+ # Repository might already exist
634
+ if "already exists" in result.stderr.lower():
635
+ print(f"Repository {repo_path} already exists, using it...")
636
+ else:
637
+ print(f"❌ Failed to create repository: {result.stderr}")
638
+ return 1
639
+ else:
640
+ print(f"✓ Created private repository: {repo_path}")
641
+
642
+ else:
643
+ # Use GitHub API directly
644
+ print("Creating repository using GitHub API...")
645
+ import urllib.request
646
+
647
+ api_url = "https://api.github.com/user/repos"
648
+ data = json.dumps({
649
+ "name": repo_name,
650
+ "private": True,
651
+ "description": "ReAlign AI session history",
652
+ "auto_init": False
653
+ }).encode('utf-8')
654
+
655
+ req = urllib.request.Request(
656
+ api_url,
657
+ data=data,
658
+ headers={
659
+ "Authorization": f"token {token}",
660
+ "Accept": "application/vnd.github.v3+json",
661
+ "Content-Type": "application/json"
662
+ },
663
+ method="POST"
664
+ )
665
+
666
+ try:
667
+ with urllib.request.urlopen(req) as response:
668
+ result = json.loads(response.read().decode('utf-8'))
669
+ print(f"✓ Created private repository: {repo_path}")
670
+ except urllib.error.HTTPError as e:
671
+ error_body = e.read().decode('utf-8')
672
+ if "already exists" in error_body.lower():
673
+ print(f"Repository {repo_path} already exists, using it...")
674
+ else:
675
+ print(f"❌ Failed to create repository: {error_body}")
676
+ return 1
677
+
678
+ except Exception as e:
679
+ logger.error(f"Failed to create repository: {e}")
680
+ print(f"❌ Failed to create repository: {e}")
681
+ print()
682
+ print("You can create the repository manually:")
683
+ print(f" 1. Go to: https://github.com/new")
684
+ print(f" 2. Name: {repo_name}")
685
+ print(f" 3. Privacy: Private")
686
+ print(f" 4. Then run: aline share configure {repo_path} --token <your-token>")
687
+ return 1
688
+
689
+ # Configure remote
690
+ print()
691
+ print("Configuring remote...")
692
+
693
+ if not tracker.setup_remote(remote_url):
694
+ print("❌ Failed to configure remote")
695
+ return 1
696
+
697
+ # Store credentials
698
+ _store_git_credentials(remote_url, token)
699
+
700
+ print("✓ Remote configured")
701
+ print()
702
+
703
+ # Update config
704
+ _update_sharing_config(repo_root, remote_url, enabled=True)
705
+
706
+ # Push initial commits
707
+ print("Pushing initial commits...")
708
+ push_success = tracker.safe_push()
709
+
710
+ if push_success:
711
+ print("✓ Initial push successful")
712
+ else:
713
+ print("⚠️ Initial push failed (you can try 'aline push' later)")
714
+
715
+ # Show success message
716
+ print()
717
+ print("╭─────────────────────────────────────────╮")
718
+ print("│ ✓ Setup Complete! │")
719
+ print("╰─────────────────────────────────────────╯")
720
+ print()
721
+ print(f"Repository: https://github.com/{repo_path}")
722
+ print()
723
+ print("Next steps:")
724
+ print(" • Push sessions: aline push")
725
+ print(" • Pull updates: aline pull")
726
+ print(" • Sync: aline sync")
727
+ print()
728
+ print("Share with teammates:")
729
+ print(f" aline init --join {repo_path}")
730
+ print()
731
+
732
+ return 0
733
+
734
+
735
+ def init_join_flow(repo: str, repo_root: Optional[Path] = None) -> int:
736
+ """
737
+ Interactive flow for joining an existing shared repository.
738
+
739
+ This is called by `aline init --join <repo>`.
740
+
741
+ Args:
742
+ repo: Repository in format 'user/repo'
743
+ repo_root: Path to repository root (uses cwd if not provided)
744
+
745
+ Returns:
746
+ Exit code (0 for success, 1 for error)
747
+ """
748
+ # Get project root
749
+ if repo_root is None:
750
+ repo_root = Path(os.getcwd()).resolve()
751
+
752
+ print("╭─────────────────────────────────────────╮")
753
+ print("│ Join ReAlign Shared Repository │")
754
+ print("╰─────────────────────────────────────────╯")
755
+ print()
756
+
757
+ # Parse repository
758
+ remote_url = _parse_remote_url(repo)
759
+ if not remote_url:
760
+ print("❌ Invalid repository format")
761
+ print()
762
+ print("Expected format: user/repo")
763
+ print(f"Example: aline init --join alice/team-sessions")
764
+ return 1
765
+
766
+ repo_path = _extract_github_repo(remote_url)
767
+ print(f"Repository: https://github.com/{repo_path}")
768
+ print()
769
+
770
+ # Initialize ReAlign if not already done
771
+ from .init import init_repository
772
+ tracker = ReAlignGitTracker(repo_root)
773
+
774
+ if not tracker.is_initialized():
775
+ print("Initializing ReAlign...")
776
+ result = init_repository(repo_path=str(repo_root), for_join=True)
777
+ if not result["success"]:
778
+ print("❌ Failed to initialize ReAlign")
779
+ return 1
780
+ print("✓ ReAlign initialized")
781
+ print()
782
+
783
+ # Check if remote already configured
784
+ if tracker.has_remote():
785
+ existing_remote = tracker.get_remote_url()
786
+ print(f"⚠️ Remote already configured: {existing_remote}")
787
+ print()
788
+ confirm = input("Reconfigure? [y/N]: ").strip().lower()
789
+ if confirm != 'y':
790
+ print("Cancelled")
791
+ return 0
792
+ print()
793
+
794
+ # Verify repository exists
795
+ print("Verifying repository access...")
796
+ import subprocess
797
+
798
+ # Try to check if repo exists using git ls-remote
799
+ check_result = subprocess.run(
800
+ ["git", "ls-remote", remote_url],
801
+ capture_output=True,
802
+ text=True,
803
+ check=False
804
+ )
805
+
806
+ if check_result.returncode != 0:
807
+ # Repository might be private, need authentication
808
+ print("Repository requires authentication")
809
+ print()
810
+ else:
811
+ print("✓ Repository found")
812
+ print()
813
+
814
+ # Get GitHub token
815
+ print("Authentication Required")
816
+ print("──────────────────────")
817
+ print()
818
+ print("You need a GitHub Personal Access Token to access this repository.")
819
+ print()
820
+ print("If you don't have a token:")
821
+ print(" 1. Go to: https://github.com/settings/tokens/new")
822
+ print(" 2. Name: 'ReAlign Sharing'")
823
+ print(" 3. Scopes: Select 'repo' (full control of private repositories)")
824
+ print(" 4. Generate token and paste below")
825
+ print()
826
+
827
+ # Open browser to token creation page
828
+ open_browser = input("Open token creation page in browser? [Y/n]: ").strip().lower()
829
+ if open_browser != 'n':
830
+ token_url = "https://github.com/settings/tokens/new?description=ReAlign%20Sharing&scopes=repo"
831
+ webbrowser.open(token_url)
832
+ print("✓ Opened in browser")
833
+ print()
834
+
835
+ # Get token securely
836
+ token = getpass.getpass("GitHub Personal Access Token: ").strip()
837
+
838
+ if not token:
839
+ print("❌ Token required")
840
+ return 1
841
+
842
+ print()
843
+
844
+ # Verify access with token
845
+ print("Verifying access...")
846
+ verify_result = subprocess.run(
847
+ ["git", "ls-remote", remote_url],
848
+ env={**os.environ, "GIT_ASKPASS": "echo", "GIT_USERNAME": "x-access-token", "GIT_PASSWORD": token},
849
+ capture_output=True,
850
+ text=True,
851
+ check=False
852
+ )
853
+
854
+ if verify_result.returncode != 0:
855
+ print("❌ Failed to access repository")
856
+ print()
857
+ print("Possible issues:")
858
+ print(" • Invalid token")
859
+ print(" • No access to repository")
860
+ print(" • Repository doesn't exist")
861
+ print()
862
+ print("Ask the repository owner to invite you:")
863
+ print(f" GitHub Settings → {repo_path} → Manage Access → Invite")
864
+ return 1
865
+
866
+ print("✓ Access verified")
867
+ print()
868
+
869
+ # Get GitHub username from token
870
+ print("Getting GitHub username...")
871
+ github_username = _get_github_username(token)
872
+ if not github_username:
873
+ print("⚠️ Could not retrieve GitHub username, using current git user")
874
+ # Fallback: try to get from git config
875
+ import subprocess
876
+ result = subprocess.run(
877
+ ["git", "config", "user.name"],
878
+ capture_output=True,
879
+ text=True,
880
+ check=False
881
+ )
882
+ github_username = result.stdout.strip() if result.returncode == 0 else None
883
+
884
+ if not github_username:
885
+ print("❌ Could not determine username for branch")
886
+ return 1
887
+
888
+ member_branch = f"{github_username}/master"
889
+ print(f"✓ Member branch: {member_branch}")
890
+ print()
891
+
892
+ # Configure remote
893
+ print("Configuring remote...")
894
+
895
+ if not tracker.setup_remote(remote_url):
896
+ print("❌ Failed to configure remote")
897
+ return 1
898
+
899
+ # Store credentials
900
+ _store_git_credentials(remote_url, token)
901
+
902
+ print("✓ Remote configured")
903
+ print()
904
+
905
+ # Pull existing sessions FIRST (before updating config)
906
+ # This ensures we get the remote's config.yaml and .gitignore
907
+ print("Pulling existing sessions from owner's master branch...")
908
+ pull_success = tracker.safe_pull()
909
+
910
+ if pull_success:
911
+ print("✓ Pulled from owner's master branch")
912
+ print()
913
+
914
+ # Create member-specific branch based on owner's master
915
+ print(f"Creating member branch: {member_branch}...")
916
+ if not tracker.create_branch(member_branch, start_point="master"):
917
+ print("❌ Failed to create member branch")
918
+ return 1
919
+ print(f"✓ Created and checked out branch: {member_branch}")
920
+ print()
921
+ else:
922
+ print("⚠️ Failed to pull from owner's master (repository might be empty)")
923
+ print("Creating member branch on empty repository...")
924
+ if not tracker.create_branch(member_branch, start_point="master"):
925
+ # If master doesn't exist, try creating a branch directly
926
+ print("⚠️ Could not create from master, attempting to create as initial branch...")
927
+ # This might happen on a completely empty repo
928
+ pass
929
+ print()
930
+
931
+ # Update config AFTER pull
932
+ # This way we preserve any existing config from remote
933
+ _update_sharing_config(repo_root, remote_url, enabled=True, member_name=github_username)
934
+
935
+ if pull_success:
936
+ # Get commit count
937
+ import subprocess
938
+ result = subprocess.run(
939
+ ["git", "rev-list", "--count", "HEAD"],
940
+ cwd=tracker.realign_dir,
941
+ capture_output=True,
942
+ text=True,
943
+ check=False
944
+ )
945
+
946
+ commit_count = result.stdout.strip() if result.returncode == 0 else "unknown"
947
+ print(f"✓ Pulled {commit_count} commit(s)")
948
+ else:
949
+ print("⚠️ Pull failed (repository might be empty)")
950
+
951
+ # Show success message
952
+ print()
953
+ print("╭─────────────────────────────────────────╮")
954
+ print("│ ✓ Successfully Joined! │")
955
+ print("╰─────────────────────────────────────────╯")
956
+ print()
957
+ print(f"Repository: https://github.com/{repo_path}")
958
+ print()
959
+ print("You can now:")
960
+ print(" • Push sessions: aline push")
961
+ print(" • Pull updates: aline pull")
962
+ print(" • Sync: aline sync")
963
+ print()
964
+
965
+ return 0