aline-ai 0.5.12__py3-none-any.whl → 0.6.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.
realign/cli.py CHANGED
@@ -7,7 +7,7 @@ from typing import Optional
7
7
  from rich.console import Console
8
8
  from rich.syntax import Syntax
9
9
 
10
- from .commands import init, config, watcher, worker, export_shares, search, upgrade, restore, add
10
+ from .commands import init, config, watcher, worker, export_shares, search, upgrade, restore, add, auth
11
11
 
12
12
  app = typer.Typer(
13
13
  name="realign",
@@ -33,6 +33,26 @@ def main(
33
33
  ctx.obj["dev"] = dev
34
34
 
35
35
  if ctx.invoked_subcommand is None:
36
+ # Check login status before launching dashboard
37
+ from .auth import is_logged_in, get_current_user
38
+
39
+ if not is_logged_in():
40
+ console.print("[yellow]You need to login before using Aline.[/yellow]")
41
+ console.print("Starting login flow...\n")
42
+
43
+ # Run login command
44
+ exit_code = auth.login_command()
45
+ if exit_code != 0:
46
+ console.print("\n[red]Login failed. Please try again with 'aline login'.[/red]")
47
+ raise typer.Exit(code=1)
48
+
49
+ # Verify login succeeded
50
+ if not is_logged_in():
51
+ console.print("\n[red]Login verification failed. Please try again.[/red]")
52
+ raise typer.Exit(code=1)
53
+
54
+ console.print() # Add spacing before dashboard launch
55
+
36
56
  # Check for updates before launching dashboard
37
57
  from .commands.upgrade import check_and_prompt_update
38
58
 
@@ -57,6 +77,257 @@ app.command(name="config")(config.config_command)
57
77
  app.command(name="upgrade")(upgrade.upgrade_command)
58
78
 
59
79
 
80
+ # Auth commands
81
+ @app.command(name="login")
82
+ def login_cli():
83
+ """Login to Aline via web browser to enable share features."""
84
+ exit_code = auth.login_command()
85
+ raise typer.Exit(code=exit_code)
86
+
87
+
88
+ @app.command(name="logout")
89
+ def logout_cli():
90
+ """Logout from Aline and clear local credentials."""
91
+ exit_code = auth.logout_command()
92
+ raise typer.Exit(code=exit_code)
93
+
94
+
95
+ @app.command(name="whoami")
96
+ def whoami_cli():
97
+ """Display current login status and user information."""
98
+ exit_code = auth.whoami_command()
99
+ raise typer.Exit(code=exit_code)
100
+
101
+
102
+ @app.command(name="doctor")
103
+ def doctor_cli(
104
+ no_restart: bool = typer.Option(False, "--no-restart", help="Only clear cache, don't restart daemons"),
105
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
106
+ ):
107
+ """
108
+ Fix common issues after code updates.
109
+
110
+ This command:
111
+ - Clears Python bytecode cache (.pyc files)
112
+ - Updates Claude Code hooks (Stop, UserPromptSubmit, PermissionRequest)
113
+ - Updates skills to latest version
114
+ - Restarts the watcher daemon (if running)
115
+ - Restarts the worker daemon (if running)
116
+
117
+ Run this after pulling new code to ensure everything uses the latest version.
118
+ """
119
+ import shutil
120
+ import subprocess
121
+ import signal
122
+ import time
123
+
124
+ # Find the project root (where src/realign is)
125
+ project_root = Path(__file__).parent.parent.parent
126
+
127
+ # 1. Clear Python cache
128
+ console.print("[bold]1. Clearing Python cache...[/bold]")
129
+ pyc_count = 0
130
+ pycache_count = 0
131
+
132
+ for pyc_file in project_root.rglob("*.pyc"):
133
+ try:
134
+ pyc_file.unlink()
135
+ pyc_count += 1
136
+ if verbose:
137
+ console.print(f" [dim]Removed: {pyc_file}[/dim]")
138
+ except Exception as e:
139
+ if verbose:
140
+ console.print(f" [yellow]Failed to remove {pyc_file}: {e}[/yellow]")
141
+
142
+ for pycache_dir in project_root.rglob("__pycache__"):
143
+ if pycache_dir.is_dir():
144
+ try:
145
+ shutil.rmtree(pycache_dir)
146
+ pycache_count += 1
147
+ if verbose:
148
+ console.print(f" [dim]Removed: {pycache_dir}[/dim]")
149
+ except Exception as e:
150
+ if verbose:
151
+ console.print(f" [yellow]Failed to remove {pycache_dir}: {e}[/yellow]")
152
+
153
+ console.print(f" [green]✓[/green] Cleared {pyc_count} .pyc files, {pycache_count} __pycache__ directories")
154
+
155
+ # 2. Update Claude Code hooks
156
+ console.print("\n[bold]2. Updating Claude Code hooks...[/bold]")
157
+ hooks_updated = []
158
+ hooks_failed = []
159
+
160
+ # Stop hook
161
+ try:
162
+ from .claude_hooks.stop_hook_installer import install_stop_hook, get_settings_path
163
+ if install_stop_hook(get_settings_path(), quiet=True, force=True):
164
+ hooks_updated.append("Stop")
165
+ if verbose:
166
+ console.print(" [dim]Stop hook updated[/dim]")
167
+ else:
168
+ hooks_failed.append("Stop")
169
+ except Exception as e:
170
+ hooks_failed.append("Stop")
171
+ if verbose:
172
+ console.print(f" [yellow]Stop hook failed: {e}[/yellow]")
173
+
174
+ # UserPromptSubmit hook
175
+ try:
176
+ from .claude_hooks.user_prompt_submit_hook_installer import install_user_prompt_submit_hook, get_settings_path as get_submit_settings_path
177
+ if install_user_prompt_submit_hook(get_submit_settings_path(), quiet=True, force=True):
178
+ hooks_updated.append("UserPromptSubmit")
179
+ if verbose:
180
+ console.print(" [dim]UserPromptSubmit hook updated[/dim]")
181
+ else:
182
+ hooks_failed.append("UserPromptSubmit")
183
+ except Exception as e:
184
+ hooks_failed.append("UserPromptSubmit")
185
+ if verbose:
186
+ console.print(f" [yellow]UserPromptSubmit hook failed: {e}[/yellow]")
187
+
188
+ # PermissionRequest hook
189
+ try:
190
+ from .claude_hooks.permission_request_hook_installer import install_permission_request_hook, get_settings_path as get_permission_settings_path
191
+ if install_permission_request_hook(get_permission_settings_path(), quiet=True, force=True):
192
+ hooks_updated.append("PermissionRequest")
193
+ if verbose:
194
+ console.print(" [dim]PermissionRequest hook updated[/dim]")
195
+ else:
196
+ hooks_failed.append("PermissionRequest")
197
+ except Exception as e:
198
+ hooks_failed.append("PermissionRequest")
199
+ if verbose:
200
+ console.print(f" [yellow]PermissionRequest hook failed: {e}[/yellow]")
201
+
202
+ if hooks_updated:
203
+ console.print(f" [green]✓[/green] Updated hooks: {', '.join(hooks_updated)}")
204
+ if hooks_failed:
205
+ console.print(f" [yellow]![/yellow] Failed hooks: {', '.join(hooks_failed)}")
206
+
207
+ # 3. Update skills
208
+ console.print("\n[bold]3. Updating skills...[/bold]")
209
+ try:
210
+ from .commands.add import add_skills_command
211
+ # Capture output by redirecting - use force=True to update
212
+ import io
213
+ import contextlib
214
+
215
+ stdout_capture = io.StringIO()
216
+ with contextlib.redirect_stdout(stdout_capture):
217
+ add_skills_command(force=True)
218
+
219
+ output = stdout_capture.getvalue()
220
+ # Count updated skills from output
221
+ updated_count = output.count("✓")
222
+ if updated_count > 0:
223
+ console.print(f" [green]✓[/green] Updated {updated_count} skill(s)")
224
+ else:
225
+ console.print(" [green]✓[/green] Skills are up to date")
226
+ if verbose and output.strip():
227
+ for line in output.strip().split("\n"):
228
+ console.print(f" [dim]{line}[/dim]")
229
+ except Exception as e:
230
+ console.print(f" [yellow]![/yellow] Failed to update skills: {e}")
231
+
232
+ if no_restart:
233
+ console.print("\n[dim]Skipping daemon restart (--no-restart)[/dim]")
234
+ console.print("\n[green]Done![/green] Aline is ready with the latest code.")
235
+ raise typer.Exit(code=0)
236
+
237
+ # 4. Restart watcher daemon
238
+ console.print("\n[bold]4. Checking watcher daemon...[/bold]")
239
+ pid_file = Path.home() / ".aline" / ".logs" / "watcher.pid"
240
+ watcher_was_running = False
241
+
242
+ if pid_file.exists():
243
+ try:
244
+ pid = int(pid_file.read_text().strip())
245
+ # Check if process is running
246
+ try:
247
+ import os
248
+ os.kill(pid, 0) # Signal 0 just checks if process exists
249
+ watcher_was_running = True
250
+ console.print(f" [dim]Found watcher daemon (PID {pid}), stopping...[/dim]")
251
+ os.kill(pid, signal.SIGTERM)
252
+ time.sleep(1)
253
+ # Force kill if still running
254
+ try:
255
+ os.kill(pid, 0)
256
+ os.kill(pid, signal.SIGKILL)
257
+ time.sleep(0.5)
258
+ except ProcessLookupError:
259
+ pass
260
+ except ProcessLookupError:
261
+ console.print(" [dim]Watcher daemon not running (stale PID file)[/dim]")
262
+ except Exception as e:
263
+ if verbose:
264
+ console.print(f" [yellow]Error checking watcher: {e}[/yellow]")
265
+
266
+ if watcher_was_running:
267
+ console.print(" [dim]Starting watcher daemon...[/dim]")
268
+ try:
269
+ subprocess.Popen(
270
+ ["python", "-m", "src.realign.watcher_daemon"],
271
+ stdout=subprocess.DEVNULL,
272
+ stderr=subprocess.DEVNULL,
273
+ start_new_session=True,
274
+ cwd=str(project_root),
275
+ )
276
+ time.sleep(2)
277
+ console.print(" [green]✓[/green] Watcher daemon restarted")
278
+ except Exception as e:
279
+ console.print(f" [red]✗[/red] Failed to restart watcher: {e}")
280
+ else:
281
+ console.print(" [dim]Watcher daemon was not running[/dim]")
282
+
283
+ # 5. Restart worker daemon
284
+ console.print("\n[bold]5. Checking worker daemon...[/bold]")
285
+ worker_pid_file = Path.home() / ".aline" / ".logs" / "worker.pid"
286
+ worker_was_running = False
287
+
288
+ if worker_pid_file.exists():
289
+ try:
290
+ pid = int(worker_pid_file.read_text().strip())
291
+ try:
292
+ import os
293
+ os.kill(pid, 0)
294
+ worker_was_running = True
295
+ console.print(f" [dim]Found worker daemon (PID {pid}), stopping...[/dim]")
296
+ os.kill(pid, signal.SIGTERM)
297
+ time.sleep(1)
298
+ try:
299
+ os.kill(pid, 0)
300
+ os.kill(pid, signal.SIGKILL)
301
+ time.sleep(0.5)
302
+ except ProcessLookupError:
303
+ pass
304
+ except ProcessLookupError:
305
+ console.print(" [dim]Worker daemon not running (stale PID file)[/dim]")
306
+ except Exception as e:
307
+ if verbose:
308
+ console.print(f" [yellow]Error checking worker: {e}[/yellow]")
309
+
310
+ if worker_was_running:
311
+ console.print(" [dim]Starting worker daemon...[/dim]")
312
+ try:
313
+ subprocess.Popen(
314
+ ["python", "-m", "src.realign.worker_daemon"],
315
+ stdout=subprocess.DEVNULL,
316
+ stderr=subprocess.DEVNULL,
317
+ start_new_session=True,
318
+ cwd=str(project_root),
319
+ )
320
+ time.sleep(2)
321
+ console.print(" [green]✓[/green] Worker daemon restarted")
322
+ except Exception as e:
323
+ console.print(f" [red]✗[/red] Failed to restart worker: {e}")
324
+ else:
325
+ console.print(" [dim]Worker daemon was not running[/dim]")
326
+
327
+ console.print("\n[green]Done![/green] Aline is ready with the latest code.")
328
+ raise typer.Exit(code=0)
329
+
330
+
60
331
  @app.command(name="search")
61
332
  def search_cli(
62
333
  query: str = typer.Argument(..., help="Search query (keywords or regex pattern)"),
@@ -0,0 +1,343 @@
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
+ Also stops watcher and worker daemons since they require authentication.
180
+
181
+ Returns:
182
+ 0 on success, 1 on error
183
+ """
184
+ credentials = load_credentials()
185
+
186
+ if not credentials:
187
+ if console:
188
+ console.print("[yellow]Not currently logged in.[/yellow]")
189
+ else:
190
+ print("Not currently logged in.")
191
+ return 0
192
+
193
+ email = credentials.email
194
+
195
+ # Stop watcher and worker daemons before logout
196
+ if console:
197
+ console.print("[dim]Stopping daemons...[/dim]")
198
+ else:
199
+ print("Stopping daemons...")
200
+
201
+ _stop_daemons()
202
+
203
+ if not clear_credentials():
204
+ if console:
205
+ console.print("[red]Error: Failed to clear credentials[/red]")
206
+ else:
207
+ print("Error: Failed to clear credentials", file=sys.stderr)
208
+ return 1
209
+
210
+ if console:
211
+ console.print(f"[green]Logged out successfully.[/green]")
212
+ console.print(f"Cleared credentials for: {email}")
213
+ else:
214
+ print("Logged out successfully.")
215
+ print(f"Cleared credentials for: {email}")
216
+
217
+ logger.info(f"Logout successful for {email}")
218
+ return 0
219
+
220
+
221
+ def _stop_daemons() -> None:
222
+ """Stop watcher and worker daemons."""
223
+ import os
224
+ import signal
225
+ import time
226
+ from pathlib import Path
227
+
228
+ def stop_daemon(name: str, pid_file: Path) -> None:
229
+ """Stop a daemon by PID file, wait for it to terminate."""
230
+ if not pid_file.exists():
231
+ return
232
+
233
+ try:
234
+ pid = int(pid_file.read_text().strip())
235
+ except Exception:
236
+ return
237
+
238
+ # Check if process is running
239
+ try:
240
+ os.kill(pid, 0)
241
+ except ProcessLookupError:
242
+ # Process already gone, clean up PID file
243
+ try:
244
+ pid_file.unlink(missing_ok=True)
245
+ except Exception:
246
+ pass
247
+ return
248
+ except PermissionError:
249
+ pass # Can't check, try to stop anyway
250
+
251
+ # Send SIGTERM
252
+ try:
253
+ os.kill(pid, signal.SIGTERM)
254
+ if console:
255
+ console.print(f" [dim]Stopping {name} daemon (PID {pid})...[/dim]")
256
+ logger.info(f"Sent SIGTERM to {name} daemon (PID {pid})")
257
+ except ProcessLookupError:
258
+ try:
259
+ pid_file.unlink(missing_ok=True)
260
+ except Exception:
261
+ pass
262
+ return
263
+ except Exception as e:
264
+ logger.debug(f"Error sending SIGTERM to {name}: {e}")
265
+ return
266
+
267
+ # Wait for process to terminate (up to 5 seconds)
268
+ for _ in range(50): # 50 * 0.1s = 5 seconds
269
+ time.sleep(0.1)
270
+ try:
271
+ os.kill(pid, 0)
272
+ except ProcessLookupError:
273
+ # Process terminated
274
+ if console:
275
+ console.print(f" [dim]{name.capitalize()} daemon stopped[/dim]")
276
+ logger.info(f"{name.capitalize()} daemon (PID {pid}) stopped")
277
+ try:
278
+ pid_file.unlink(missing_ok=True)
279
+ except Exception:
280
+ pass
281
+ return
282
+ except PermissionError:
283
+ break # Can't check anymore
284
+
285
+ # Process still running, try SIGKILL
286
+ try:
287
+ os.kill(pid, signal.SIGKILL)
288
+ time.sleep(0.5)
289
+ if console:
290
+ console.print(f" [dim]{name.capitalize()} daemon force killed[/dim]")
291
+ logger.info(f"{name.capitalize()} daemon (PID {pid}) force killed")
292
+ except ProcessLookupError:
293
+ pass
294
+ except Exception as e:
295
+ logger.debug(f"Error sending SIGKILL to {name}: {e}")
296
+
297
+ # Clean up PID file
298
+ try:
299
+ pid_file.unlink(missing_ok=True)
300
+ except Exception:
301
+ pass
302
+
303
+ # Stop watcher daemon
304
+ watcher_pid_file = Path.home() / ".aline" / ".logs" / "watcher.pid"
305
+ stop_daemon("watcher", watcher_pid_file)
306
+
307
+ # Stop worker daemon
308
+ worker_pid_file = Path.home() / ".aline" / ".logs" / "worker.pid"
309
+ stop_daemon("worker", worker_pid_file)
310
+
311
+
312
+ def whoami_command() -> int:
313
+ """
314
+ Display current login status.
315
+
316
+ Returns:
317
+ 0 if logged in, 1 if not logged in
318
+ """
319
+ credentials = get_current_user()
320
+
321
+ if not credentials:
322
+ if console:
323
+ console.print("[yellow]Not logged in.[/yellow]")
324
+ console.print("Run 'aline login' to authenticate.")
325
+ else:
326
+ print("Not logged in.")
327
+ print("Run 'aline login' to authenticate.")
328
+ return 1
329
+
330
+ if console:
331
+ console.print(f"[green]Logged in as:[/green] [bold]{credentials.email}[/bold]")
332
+ console.print(f"[dim]User ID:[/dim] {credentials.user_id}")
333
+ if credentials.provider:
334
+ console.print(f"[dim]Provider:[/dim] {credentials.provider}")
335
+ console.print(f"[dim]Token expires:[/dim] {credentials.expires_at.strftime('%Y-%m-%d %H:%M:%S UTC')}")
336
+ else:
337
+ print(f"Logged in as: {credentials.email}")
338
+ print(f"User ID: {credentials.user_id}")
339
+ if credentials.provider:
340
+ print(f"Provider: {credentials.provider}")
341
+ print(f"Token expires: {credentials.expires_at.strftime('%Y-%m-%d %H:%M:%S UTC')}")
342
+
343
+ return 0