aline-ai 0.5.11__py3-none-any.whl → 0.5.13__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.
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Authentication commands for Aline CLI.
4
+
5
+ Commands:
6
+ - aline login - Login via web browser
7
+ - aline logout - Clear local credentials
8
+ - aline whoami - Show current login status
9
+ """
10
+
11
+ import sys
12
+ from typing import Optional
13
+
14
+ try:
15
+ from rich.console import Console
16
+ from rich.prompt import Prompt
17
+ RICH_AVAILABLE = True
18
+ except ImportError:
19
+ RICH_AVAILABLE = False
20
+
21
+ from ..auth import (
22
+ is_logged_in,
23
+ get_current_user,
24
+ load_credentials,
25
+ save_credentials,
26
+ clear_credentials,
27
+ open_login_page,
28
+ validate_cli_token,
29
+ find_free_port,
30
+ start_callback_server,
31
+ HTTPX_AVAILABLE,
32
+ )
33
+ from ..config import ReAlignConfig
34
+ from ..logging_config import setup_logger
35
+
36
+ logger = setup_logger("realign.commands.auth", "auth.log")
37
+
38
+ if RICH_AVAILABLE:
39
+ console = Console()
40
+ else:
41
+ console = None
42
+
43
+
44
+ def login_command() -> int:
45
+ """
46
+ Login to Aline via web browser.
47
+
48
+ Opens the web login page with automatic callback - no manual token copy needed.
49
+
50
+ Returns:
51
+ 0 on success, 1 on error
52
+ """
53
+ # Check dependencies
54
+ if not HTTPX_AVAILABLE:
55
+ print("Error: httpx package not installed", file=sys.stderr)
56
+ print("Install it with: pip install httpx", file=sys.stderr)
57
+ return 1
58
+
59
+ # Check if already logged in
60
+ credentials = load_credentials()
61
+ if credentials and is_logged_in():
62
+ if console:
63
+ console.print(f"[yellow]Already logged in as {credentials.email}[/yellow]")
64
+ console.print("Run 'aline logout' first if you want to switch accounts.")
65
+ else:
66
+ print(f"Already logged in as {credentials.email}")
67
+ print("Run 'aline logout' first if you want to switch accounts.")
68
+ return 0
69
+
70
+ # Start local callback server
71
+ port = find_free_port()
72
+
73
+ if console:
74
+ console.print("[cyan]Opening browser for login...[/cyan]")
75
+ console.print("[dim]Waiting for authentication...[/dim]\n")
76
+ else:
77
+ print("Opening browser for login...")
78
+ print("Waiting for authentication...\n")
79
+
80
+ # Open browser with callback URL
81
+ login_url = open_login_page(callback_port=port)
82
+
83
+ if console:
84
+ console.print(f"[dim]If browser doesn't open, visit:[/dim]")
85
+ console.print(f"[link={login_url}]{login_url}[/link]\n")
86
+ else:
87
+ print(f"If browser doesn't open, visit:")
88
+ print(f"{login_url}\n")
89
+
90
+ # Wait for callback with token
91
+ cli_token, error = start_callback_server(port, timeout=300)
92
+
93
+ if error:
94
+ if console:
95
+ console.print(f"[red]Error: {error}[/red]")
96
+ else:
97
+ print(f"Error: {error}", file=sys.stderr)
98
+ return 1
99
+
100
+ if not cli_token:
101
+ if console:
102
+ console.print("[red]Error: No token received[/red]")
103
+ console.print("Please try again with 'aline login'")
104
+ else:
105
+ print("Error: No token received", file=sys.stderr)
106
+ print("Please try again with 'aline login'")
107
+ return 1
108
+
109
+ # Validate token
110
+ if console:
111
+ console.print("[cyan]Validating token...[/cyan]")
112
+ else:
113
+ print("Validating token...")
114
+
115
+ credentials = validate_cli_token(cli_token)
116
+
117
+ if not credentials:
118
+ if console:
119
+ console.print("[red]Error: Invalid or expired token[/red]")
120
+ console.print("Please try again with 'aline login'")
121
+ else:
122
+ print("Error: Invalid or expired token", file=sys.stderr)
123
+ print("Please try again with 'aline login'")
124
+ return 1
125
+
126
+ # Save credentials
127
+ if not save_credentials(credentials):
128
+ if console:
129
+ console.print("[red]Error: Failed to save credentials[/red]")
130
+ else:
131
+ print("Error: Failed to save credentials", file=sys.stderr)
132
+ return 1
133
+
134
+ # Sync Supabase uid to local config
135
+ # This ensures all new Events/Sessions/Turns use the same uid as shares
136
+ try:
137
+ config = ReAlignConfig.load()
138
+ old_uid = config.uid
139
+ config.uid = credentials.user_id
140
+ # Use email as user_name if not already set
141
+ if not config.user_name:
142
+ config.user_name = credentials.email.split("@")[0] # Use email prefix as username
143
+ config.save()
144
+ logger.info(f"Synced Supabase uid to config: {credentials.user_id[:8]}... (was: {old_uid[:8] if old_uid else 'not set'}...)")
145
+
146
+ # V18: Upsert user info to users table
147
+ try:
148
+ from ..db import get_database
149
+ db = get_database()
150
+ db.upsert_user(config.uid, config.user_name)
151
+ except Exception as e:
152
+ logger.debug(f"Failed to upsert user to users table: {e}")
153
+ except Exception as e:
154
+ # Non-fatal: continue even if config sync fails
155
+ logger.warning(f"Failed to sync uid to config: {e}")
156
+
157
+ # Success
158
+ if console:
159
+ console.print(f"\n[green]Login successful![/green]")
160
+ console.print(f"Logged in as: [bold]{credentials.email}[/bold]")
161
+ if credentials.provider and credentials.provider != "email":
162
+ console.print(f"Provider: {credentials.provider}")
163
+ console.print(f"[dim]User ID synced to local config[/dim]")
164
+ else:
165
+ print(f"\nLogin successful!")
166
+ print(f"Logged in as: {credentials.email}")
167
+ if credentials.provider and credentials.provider != "email":
168
+ print(f"Provider: {credentials.provider}")
169
+ print("User ID synced to local config")
170
+
171
+ logger.info(f"Login successful for {credentials.email}")
172
+ return 0
173
+
174
+
175
+ def logout_command() -> int:
176
+ """
177
+ Logout from Aline and clear local credentials.
178
+
179
+ Returns:
180
+ 0 on success, 1 on error
181
+ """
182
+ credentials = load_credentials()
183
+
184
+ if not credentials:
185
+ if console:
186
+ console.print("[yellow]Not currently logged in.[/yellow]")
187
+ else:
188
+ print("Not currently logged in.")
189
+ return 0
190
+
191
+ email = credentials.email
192
+
193
+ if not clear_credentials():
194
+ if console:
195
+ console.print("[red]Error: Failed to clear credentials[/red]")
196
+ else:
197
+ print("Error: Failed to clear credentials", file=sys.stderr)
198
+ return 1
199
+
200
+ if console:
201
+ console.print(f"[green]Logged out successfully.[/green]")
202
+ console.print(f"Cleared credentials for: {email}")
203
+ else:
204
+ print("Logged out successfully.")
205
+ print(f"Cleared credentials for: {email}")
206
+
207
+ logger.info(f"Logout successful for {email}")
208
+ return 0
209
+
210
+
211
+ def whoami_command() -> int:
212
+ """
213
+ Display current login status.
214
+
215
+ Returns:
216
+ 0 if logged in, 1 if not logged in
217
+ """
218
+ credentials = get_current_user()
219
+
220
+ if not credentials:
221
+ if console:
222
+ console.print("[yellow]Not logged in.[/yellow]")
223
+ console.print("Run 'aline login' to authenticate.")
224
+ else:
225
+ print("Not logged in.")
226
+ print("Run 'aline login' to authenticate.")
227
+ return 1
228
+
229
+ if console:
230
+ console.print(f"[green]Logged in as:[/green] [bold]{credentials.email}[/bold]")
231
+ console.print(f"[dim]User ID:[/dim] {credentials.user_id}")
232
+ if credentials.provider:
233
+ console.print(f"[dim]Provider:[/dim] {credentials.provider}")
234
+ console.print(f"[dim]Token expires:[/dim] {credentials.expires_at.strftime('%Y-%m-%d %H:%M:%S UTC')}")
235
+ else:
236
+ print(f"Logged in as: {credentials.email}")
237
+ print(f"User ID: {credentials.user_id}")
238
+ if credentials.provider:
239
+ print(f"Provider: {credentials.provider}")
240
+ print(f"Token expires: {credentials.expires_at.strftime('%Y-%m-%d %H:%M:%S UTC')}")
241
+
242
+ return 0
@@ -25,6 +25,7 @@ from typing import Any, List, Dict, Optional, Tuple, Set, Callable
25
25
  from ..logging_config import setup_logger
26
26
  from ..db.base import SessionRecord, TurnRecord
27
27
  from ..llm_client import call_llm, extract_json
28
+ from ..auth import get_auth_headers, is_logged_in
28
29
 
29
30
  logger = setup_logger("realign.commands.export_shares", "export_shares.log")
30
31
 
@@ -177,9 +178,9 @@ class ExportableSession:
177
178
  last_activity_at: Optional[datetime] # Last activity time
178
179
  turn_count: int # Number of turns in session
179
180
  turns: List[TurnRecord] # List of turns
180
- # V9: creator information
181
- creator_name: Optional[str] = None # Username who created the session
182
- creator_id: Optional[str] = None # User UUID
181
+ # V18: user identity
182
+ created_by: Optional[str] = None # Creator UID
183
+ shared_by: Optional[str] = None # Sharer UID
183
184
 
184
185
 
185
186
  def get_sessions_for_export(
@@ -215,8 +216,8 @@ def get_sessions_for_export(
215
216
  last_activity_at=session.last_activity_at,
216
217
  turn_count=len(turns),
217
218
  turns=turns,
218
- creator_name=session.creator_name,
219
- creator_id=session.creator_id,
219
+ created_by=session.created_by,
220
+ shared_by=session.shared_by,
220
221
  )
221
222
  )
222
223
 
@@ -298,8 +299,8 @@ def build_exportable_sessions_from_records(
298
299
  last_activity_at=session.last_activity_at,
299
300
  turn_count=len(turns),
300
301
  turns=turns,
301
- creator_name=session.creator_name,
302
- creator_id=session.creator_id,
302
+ created_by=session.created_by,
303
+ shared_by=session.shared_by,
303
304
  )
304
305
  )
305
306
  return exportable
@@ -803,8 +804,8 @@ def build_enhanced_conversation_data(
803
804
  full_event.created_at.isoformat() if full_event.created_at else None
804
805
  )
805
806
  event_data["metadata"] = full_event.metadata or {}
806
- event_data["creator_name"] = full_event.creator_name
807
- event_data["creator_id"] = full_event.creator_id
807
+ event_data["created_by"] = full_event.created_by
808
+ event_data["shared_by"] = full_event.shared_by
808
809
 
809
810
  # Build sessions data with turn structure
810
811
  sessions_data = []
@@ -867,13 +868,11 @@ def build_enhanced_conversation_data(
867
868
  ),
868
869
  "model_name": turn.model_name,
869
870
  "git_commit_hash": turn.git_commit_hash,
870
- "creator_name": turn.creator_name,
871
- "creator_id": turn.creator_id,
872
871
  "messages": messages, # Structured messages for this turn
873
872
  }
874
873
  turns_data.append(turn_data)
875
874
 
876
- # Build session data (V9: includes creator fields)
875
+ # Build session data (V18: created_by/shared_by)
877
876
  session_data = {
878
877
  "session_id": session.session_id,
879
878
  "session_type": session.session_type,
@@ -884,8 +883,8 @@ def build_enhanced_conversation_data(
884
883
  "last_activity_at": (
885
884
  session.last_activity_at.isoformat() if session.last_activity_at else None
886
885
  ),
887
- "creator_name": session.creator_name,
888
- "creator_id": session.creator_id,
886
+ "created_by": session.created_by,
887
+ "shared_by": session.shared_by,
889
888
  "turns": turns_data,
890
889
  }
891
890
  sessions_data.append(session_data)
@@ -1688,9 +1687,13 @@ def _standard_upload(
1688
1687
  if ui_metadata:
1689
1688
  payload["ui_metadata"] = ui_metadata
1690
1689
 
1690
+ # Include auth headers for Bearer token authentication
1691
+ headers = get_auth_headers()
1692
+
1691
1693
  response = httpx.post(
1692
1694
  f"{backend_url}/api/share/create",
1693
1695
  json=payload,
1696
+ headers=headers,
1694
1697
  timeout=30.0,
1695
1698
  )
1696
1699
  response.raise_for_status()
@@ -1705,12 +1708,14 @@ def _upload_chunks_and_complete(
1705
1708
  upload_id: str,
1706
1709
  backend_url: str,
1707
1710
  progress_callback: Optional[Callable] = None,
1711
+ auth_headers: Optional[Dict[str, str]] = None,
1708
1712
  ) -> None:
1709
1713
  """
1710
1714
  Helper function to upload chunks and complete the upload.
1711
1715
  Can be run in background thread.
1712
1716
  """
1713
1717
  total_chunks = len(chunks)
1718
+ headers = auth_headers or {}
1714
1719
 
1715
1720
  # Upload each chunk
1716
1721
  for i, chunk in enumerate(chunks):
@@ -1727,6 +1732,7 @@ def _upload_chunks_and_complete(
1727
1732
  response = httpx.post(
1728
1733
  f"{backend_url}/api/share/chunk/upload",
1729
1734
  json=chunk_payload,
1735
+ headers=headers,
1730
1736
  timeout=60.0, # Longer timeout for chunk uploads
1731
1737
  )
1732
1738
  response.raise_for_status()
@@ -1748,6 +1754,7 @@ def _upload_chunks_and_complete(
1748
1754
  response = httpx.post(
1749
1755
  f"{backend_url}/api/share/chunk/complete",
1750
1756
  json={"upload_id": upload_id},
1757
+ headers=headers,
1751
1758
  timeout=60.0,
1752
1759
  )
1753
1760
  response.raise_for_status()
@@ -1806,6 +1813,9 @@ def _chunked_upload(
1806
1813
  if progress_callback:
1807
1814
  progress_callback(0, total_chunks + 2, "Initializing chunked upload...")
1808
1815
 
1816
+ # Get auth headers for Bearer token authentication
1817
+ auth_headers = get_auth_headers()
1818
+
1809
1819
  # Step 1: Initialize upload session (now returns share_url immediately)
1810
1820
  try:
1811
1821
  init_payload = {
@@ -1823,6 +1833,7 @@ def _chunked_upload(
1823
1833
  response = httpx.post(
1824
1834
  f"{backend_url}/api/share/chunk/init",
1825
1835
  json=init_payload,
1836
+ headers=auth_headers,
1826
1837
  timeout=30.0,
1827
1838
  )
1828
1839
  response.raise_for_status()
@@ -1847,7 +1858,7 @@ def _chunked_upload(
1847
1858
  # but user already has the share URL displayed
1848
1859
  thread = threading.Thread(
1849
1860
  target=_upload_chunks_and_complete,
1850
- args=(chunks, upload_id, backend_url, None), # No callback in background
1861
+ args=(chunks, upload_id, backend_url, None, auth_headers), # No callback in background
1851
1862
  daemon=False, # Important: let thread complete before process exits
1852
1863
  )
1853
1864
  thread.start()
@@ -1862,7 +1873,7 @@ def _chunked_upload(
1862
1873
  }
1863
1874
 
1864
1875
  # Foreground mode: upload chunks synchronously
1865
- _upload_chunks_and_complete(chunks, upload_id, backend_url, progress_callback)
1876
+ _upload_chunks_and_complete(chunks, upload_id, backend_url, progress_callback, auth_headers)
1866
1877
 
1867
1878
  return {
1868
1879
  "share_id": share_id,
@@ -1917,11 +1928,13 @@ def upload_to_backend_unencrypted(
1917
1928
  else:
1918
1929
  logger.info(f"Payload size ({payload_size / 1024:.2f}KB), using standard upload")
1919
1930
  print(f"📤 Using standard upload...")
1920
- # Standard upload
1931
+ # Standard upload with auth headers
1921
1932
  try:
1933
+ headers = get_auth_headers()
1922
1934
  response = httpx.post(
1923
1935
  f"{backend_url}/api/share/create",
1924
1936
  json=full_payload,
1937
+ headers=headers,
1925
1938
  timeout=30.0,
1926
1939
  )
1927
1940
  response.raise_for_status()
@@ -1975,6 +1988,9 @@ def _chunked_upload_unencrypted(
1975
1988
  if progress_callback:
1976
1989
  progress_callback(0, total_chunks + 2, "Initializing chunked upload...")
1977
1990
 
1991
+ # Get auth headers for Bearer token authentication
1992
+ auth_headers = get_auth_headers()
1993
+
1978
1994
  # Step 1: Initialize upload session (now returns share_url immediately)
1979
1995
  try:
1980
1996
  init_payload = {
@@ -1988,6 +2004,7 @@ def _chunked_upload_unencrypted(
1988
2004
  response = httpx.post(
1989
2005
  f"{backend_url}/api/share/chunk/init",
1990
2006
  json=init_payload,
2007
+ headers=auth_headers,
1991
2008
  timeout=30.0,
1992
2009
  )
1993
2010
  response.raise_for_status()
@@ -2012,7 +2029,7 @@ def _chunked_upload_unencrypted(
2012
2029
  # but user already has the share URL displayed
2013
2030
  thread = threading.Thread(
2014
2031
  target=_upload_chunks_and_complete,
2015
- args=(chunks, upload_id, backend_url, None), # No callback in background
2032
+ args=(chunks, upload_id, backend_url, None, auth_headers), # No callback in background
2016
2033
  daemon=False, # Important: let thread complete before process exits
2017
2034
  )
2018
2035
  thread.start()
@@ -2027,7 +2044,7 @@ def _chunked_upload_unencrypted(
2027
2044
  }
2028
2045
 
2029
2046
  # Foreground mode: upload chunks synchronously
2030
- _upload_chunks_and_complete(chunks, upload_id, backend_url, progress_callback)
2047
+ _upload_chunks_and_complete(chunks, upload_id, backend_url, progress_callback, auth_headers)
2031
2048
 
2032
2049
  return {
2033
2050
  "share_id": share_id,
@@ -3036,16 +3053,22 @@ def export_shares_interactive_command(
3036
3053
  # Check dependencies
3037
3054
  if not CRYPTO_AVAILABLE:
3038
3055
  if not json_output:
3039
- print("Error: cryptography package not installed", file=sys.stderr)
3056
+ print("Error: cryptography package not installed", file=sys.stderr)
3040
3057
  print("Install it with: pip install cryptography", file=sys.stderr)
3041
3058
  return 1
3042
3059
 
3043
3060
  if not HTTPX_AVAILABLE:
3044
3061
  if not json_output:
3045
- print("Error: httpx package not installed", file=sys.stderr)
3062
+ print("Error: httpx package not installed", file=sys.stderr)
3046
3063
  print("Install it with: pip install httpx", file=sys.stderr)
3047
3064
  return 1
3048
3065
 
3066
+ # Check authentication - require login to create shares
3067
+ if not is_logged_in():
3068
+ if not json_output:
3069
+ print("Error: Not logged in. Please run 'aline login' first.", file=sys.stderr)
3070
+ return 1
3071
+
3049
3072
  # Get backend URL
3050
3073
  if backend_url is None:
3051
3074
  # Try to load from config
@@ -288,8 +288,9 @@ def import_v2_data(
288
288
  slack_message=None,
289
289
  share_url=share_url,
290
290
  commit_hashes=[],
291
- creator_name=event_data.get("creator_name"), # V9: preserve creator info
292
- creator_id=event_data.get("creator_id"),
291
+ # V18: user identity (with backward compatibility for old format)
292
+ created_by=event_data.get("created_by") or event_data.get("uid") or event_data.get("creator_id"),
293
+ shared_by=config.uid, # Current user is the importer
293
294
  )
294
295
 
295
296
  # Use sync_events for both create and update (upsert behavior)
@@ -393,8 +394,9 @@ def import_session_with_turns(
393
394
  summary_status="completed",
394
395
  summary_locked_until=None,
395
396
  summary_error=None,
396
- creator_name=session_data.get("creator_name"), # V9: preserve creator info
397
- creator_id=session_data.get("creator_id"),
397
+ # V18: user identity (with backward compatibility for old format)
398
+ created_by=session_data.get("created_by") or session_data.get("uid") or session_data.get("creator_id"),
399
+ shared_by=config.uid, # Current user is the importer
398
400
  )
399
401
 
400
402
  if existing_session and force:
@@ -423,8 +425,8 @@ def import_session_with_turns(
423
425
  summary_status = ?,
424
426
  summary_locked_until = ?,
425
427
  summary_error = ?,
426
- creator_name = ?,
427
- creator_id = ?
428
+ created_by = ?,
429
+ shared_by = ?
428
430
  WHERE id = ?
429
431
  """,
430
432
  (
@@ -434,8 +436,8 @@ def import_session_with_turns(
434
436
  session.summary_status,
435
437
  session.summary_locked_until,
436
438
  session.summary_error,
437
- session.creator_name,
438
- session.creator_id,
439
+ session.created_by,
440
+ session.shared_by,
439
441
  session.id,
440
442
  ),
441
443
  )
@@ -473,8 +475,6 @@ def import_session_with_turns(
473
475
  timestamp=parse_datetime(turn_data.get("timestamp")) or datetime.now(),
474
476
  created_at=datetime.now(),
475
477
  git_commit_hash=turn_data.get("git_commit_hash"),
476
- creator_name=turn_data.get("creator_name"), # V9: preserve creator info
477
- creator_id=turn_data.get("creator_id"),
478
478
  )
479
479
 
480
480
  # Store turn content (JSONL)
realign/commands/init.py CHANGED
@@ -9,7 +9,6 @@ from rich.console import Console
9
9
  from ..config import (
10
10
  ReAlignConfig,
11
11
  get_default_config_content,
12
- generate_user_id,
13
12
  generate_random_username,
14
13
  )
15
14
 
@@ -19,7 +18,7 @@ console = Console()
19
18
  # tmux config template for Aline-managed dashboard sessions.
20
19
  # Stored at ~/.aline/tmux/tmux.conf and sourced by the dashboard tmux bootstrap.
21
20
  # Bump this version when the tmux config changes to trigger auto-update on `aline init`.
22
- _TMUX_CONFIG_VERSION = 2
21
+ _TMUX_CONFIG_VERSION = 8
23
22
 
24
23
 
25
24
  def _get_tmux_config() -> str:
@@ -53,16 +52,25 @@ set -s escape-time 0
53
52
  # Better scrolling: enter copy-mode with -e so scrolling to bottom exits it.
54
53
  bind-key -n WheelUpPane if-shell -F -t = "#{mouse_any_flag}" "send-keys -M" "if -Ft= '#{pane_in_mode}' 'send-keys -M' 'copy-mode -e -t ='"
55
54
 
56
- # macOS clipboard (pbcopy). Only set bindings if pbcopy is available.
57
- if-shell 'command -v pbcopy >/dev/null 2>&1' '
58
- bind -T copy-mode-vi MouseDragEnd1Pane send -X copy-pipe "pbcopy" \; if -F "#{==:#{scroll_position},0}" "send -X cancel"
59
- bind -T copy-mode MouseDragEnd1Pane send -X copy-pipe "pbcopy" \; if -F "#{==:#{scroll_position},0}" "send -X cancel"
60
- ' ''
55
+ # macOS clipboard: copy selection to clipboard when drag ends.
56
+ # Use copy-pipe-no-clear to preserve selection highlight after copying.
57
+ bind -T copy-mode-vi MouseDragEnd1Pane send -X copy-pipe-no-clear "pbcopy"
58
+ bind -T copy-mode MouseDragEnd1Pane send -X copy-pipe-no-clear "pbcopy"
61
59
 
62
- # Prevent mouse click from exiting copy-mode (stay in copy mode, just clear selection).
60
+ # MouseDrag1Pane: Clear old selection and start new one when dragging begins.
61
+ # This ensures selection only clears when starting a NEW drag, not on click.
62
+ bind -T copy-mode-vi MouseDrag1Pane select-pane \; send -X clear-selection \; send -X begin-selection
63
+ bind -T copy-mode MouseDrag1Pane select-pane \; send -X clear-selection \; send -X begin-selection
64
+
65
+ # MouseDown1Pane: Click clears selection but stays in copy-mode (no scroll).
66
+ # To exit copy-mode: scroll to bottom (auto-exit) or press q/Escape.
63
67
  bind -T copy-mode-vi MouseDown1Pane select-pane \; send -X clear-selection
64
68
  bind -T copy-mode MouseDown1Pane select-pane \; send -X clear-selection
65
69
 
70
+ # Escape key: exit copy-mode (also use this before Cmd+V paste in copy-mode).
71
+ bind -T copy-mode-vi Escape send -X cancel
72
+ bind -T copy-mode Escape send -X cancel
73
+
66
74
  # Type-to-Exit: Typing any alphanumeric character exits copy-mode and sends the key.
67
75
  """
68
76
  def _tmux_quote(value: str) -> str:
@@ -638,45 +646,23 @@ def init_global(
638
646
  # Load config
639
647
  config = ReAlignConfig.load()
640
648
 
641
- # User identity setup (V9)
642
- if not config.user_id:
649
+ # User identity setup (V17: uid from Supabase login)
650
+ if not config.uid:
643
651
  console.print("\n[bold blue]═══ User Identity Setup ═══[/bold blue]")
644
652
  console.print(
645
- "ReAlign needs to identify you for tracking session ownership.\n"
653
+ "Aline requires login for user identification.\n"
646
654
  )
647
-
648
- # Generate user UUID (based on MAC address)
649
- config.user_id = generate_user_id()
650
- console.print(f"Generated user ID: [cyan]{config.user_id[:8]}...[/cyan]\n")
651
-
652
- # Prompt user for username
653
- try:
654
- from rich.prompt import Prompt
655
-
656
- user_input = Prompt.ask(
657
- "[cyan]Enter your username (or press Enter for auto-generated)[/cyan]",
658
- default="",
659
- )
660
- except ImportError:
661
- user_input = input(
662
- "Enter your username (or press Enter for auto-generated): "
663
- ).strip()
664
-
665
- if user_input:
666
- config.user_name = user_input
667
- else:
668
- # Auto-generate username
655
+ console.print(
656
+ "[yellow]Run 'aline login' to authenticate with your account.[/yellow]\n"
657
+ )
658
+ # If user_name is also not set, generate a temporary one
659
+ if not config.user_name:
669
660
  config.user_name = generate_random_username()
661
+ config.save()
670
662
  console.print(
671
- f"Auto-generated username: [yellow]{config.user_name}[/yellow]"
663
+ f"Auto-generated username: [yellow]{config.user_name}[/yellow] (will update on login)\n"
672
664
  )
673
665
 
674
- # Save config with user identity
675
- config.save()
676
- console.print(
677
- f"[green]✓[/green] User identity saved: [bold]{config.user_name}[/bold] ([dim]{config.user_id[:8]}...[/dim])\n"
678
- )
679
-
680
666
  # Initialize database
681
667
  db_path = Path(config.sqlite_db_path).expanduser()
682
668
  db_path.parent.mkdir(parents=True, exist_ok=True)