aline-ai 0.5.3__py3-none-any.whl → 0.5.5__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 (80) hide show
  1. {aline_ai-0.5.3.dist-info → aline_ai-0.5.5.dist-info}/METADATA +1 -1
  2. aline_ai-0.5.5.dist-info/RECORD +93 -0
  3. realign/__init__.py +1 -1
  4. realign/adapters/antigravity.py +28 -20
  5. realign/adapters/base.py +46 -50
  6. realign/adapters/claude.py +14 -14
  7. realign/adapters/codex.py +7 -7
  8. realign/adapters/gemini.py +11 -11
  9. realign/adapters/registry.py +14 -10
  10. realign/claude_detector.py +2 -2
  11. realign/claude_hooks/__init__.py +3 -3
  12. realign/claude_hooks/permission_request_hook.py +35 -0
  13. realign/claude_hooks/permission_request_hook_installer.py +31 -32
  14. realign/claude_hooks/stop_hook.py +4 -1
  15. realign/claude_hooks/stop_hook_installer.py +30 -31
  16. realign/cli.py +24 -0
  17. realign/codex_detector.py +11 -11
  18. realign/commands/add.py +361 -35
  19. realign/commands/config.py +3 -12
  20. realign/commands/context.py +3 -1
  21. realign/commands/export_shares.py +86 -127
  22. realign/commands/import_shares.py +145 -155
  23. realign/commands/init.py +166 -30
  24. realign/commands/restore.py +18 -6
  25. realign/commands/search.py +14 -42
  26. realign/commands/upgrade.py +155 -11
  27. realign/commands/watcher.py +98 -219
  28. realign/commands/worker.py +29 -6
  29. realign/config.py +25 -20
  30. realign/context.py +1 -3
  31. realign/dashboard/app.py +4 -4
  32. realign/dashboard/screens/create_event.py +3 -1
  33. realign/dashboard/screens/event_detail.py +14 -6
  34. realign/dashboard/screens/session_detail.py +3 -1
  35. realign/dashboard/screens/share_import.py +7 -3
  36. realign/dashboard/tmux_manager.py +91 -22
  37. realign/dashboard/widgets/config_panel.py +85 -1
  38. realign/dashboard/widgets/events_table.py +3 -1
  39. realign/dashboard/widgets/header.py +1 -0
  40. realign/dashboard/widgets/search_panel.py +37 -27
  41. realign/dashboard/widgets/sessions_table.py +24 -15
  42. realign/dashboard/widgets/terminal_panel.py +207 -17
  43. realign/dashboard/widgets/watcher_panel.py +6 -2
  44. realign/dashboard/widgets/worker_panel.py +10 -1
  45. realign/db/__init__.py +1 -1
  46. realign/db/base.py +5 -15
  47. realign/db/locks.py +0 -1
  48. realign/db/migration.py +82 -76
  49. realign/db/schema.py +2 -6
  50. realign/db/sqlite_db.py +23 -41
  51. realign/events/__init__.py +0 -1
  52. realign/events/event_summarizer.py +27 -15
  53. realign/events/session_summarizer.py +29 -15
  54. realign/file_lock.py +1 -0
  55. realign/hooks.py +150 -60
  56. realign/logging_config.py +12 -15
  57. realign/mcp_server.py +30 -51
  58. realign/mcp_watcher.py +0 -1
  59. realign/models/event.py +29 -20
  60. realign/prompts/__init__.py +7 -7
  61. realign/prompts/presets.py +15 -11
  62. realign/redactor.py +99 -59
  63. realign/triggers/__init__.py +9 -9
  64. realign/triggers/antigravity_trigger.py +30 -28
  65. realign/triggers/base.py +4 -3
  66. realign/triggers/claude_trigger.py +104 -85
  67. realign/triggers/codex_trigger.py +15 -5
  68. realign/triggers/gemini_trigger.py +57 -47
  69. realign/triggers/next_turn_trigger.py +3 -1
  70. realign/triggers/registry.py +6 -2
  71. realign/triggers/turn_status.py +3 -1
  72. realign/watcher_core.py +306 -131
  73. realign/watcher_daemon.py +8 -8
  74. realign/worker_core.py +3 -1
  75. realign/worker_daemon.py +3 -1
  76. aline_ai-0.5.3.dist-info/RECORD +0 -93
  77. {aline_ai-0.5.3.dist-info → aline_ai-0.5.5.dist-info}/WHEEL +0 -0
  78. {aline_ai-0.5.3.dist-info → aline_ai-0.5.5.dist-info}/entry_points.txt +0 -0
  79. {aline_ai-0.5.3.dist-info → aline_ai-0.5.5.dist-info}/licenses/LICENSE +0 -0
  80. {aline_ai-0.5.3.dist-info → aline_ai-0.5.5.dist-info}/top_level.txt +0 -0
@@ -14,18 +14,18 @@ from ..triggers.gemini_trigger import GeminiTrigger
14
14
 
15
15
  class GeminiAdapter(SessionAdapter):
16
16
  """Adapter for Gemini CLI sessions."""
17
-
17
+
18
18
  name = "gemini"
19
19
  trigger_class = GeminiTrigger
20
-
20
+
21
21
  def discover_sessions(self) -> List[Path]:
22
22
  """Find all active Gemini CLI sessions."""
23
23
  sessions = []
24
24
  gemini_tmp = Path.home() / ".gemini" / "tmp"
25
-
25
+
26
26
  if not gemini_tmp.exists():
27
27
  return sessions
28
-
28
+
29
29
  try:
30
30
  for project_dir in gemini_tmp.iterdir():
31
31
  if project_dir.is_dir():
@@ -34,13 +34,13 @@ class GeminiAdapter(SessionAdapter):
34
34
  sessions.extend(chats_dir.glob("session-*.json"))
35
35
  except Exception:
36
36
  pass
37
-
37
+
38
38
  return sessions
39
-
39
+
40
40
  def discover_sessions_for_project(self, project_path: Path) -> List[Path]:
41
41
  """
42
42
  Find sessions for a specific project.
43
-
43
+
44
44
  For Gemini CLI, project directories are hashed, so it's easier to scan all
45
45
  and check the encoded project path or project hash if we knew it.
46
46
  Actually GeminiTrigger might have some logic for this.
@@ -49,18 +49,18 @@ class GeminiAdapter(SessionAdapter):
49
49
  all_sessions = self.discover_sessions()
50
50
  matching = []
51
51
  abs_project = str(project_path.resolve())
52
-
52
+
53
53
  for s in all_sessions:
54
54
  path = self.extract_project_path(s)
55
55
  if path and str(path.resolve()) == abs_project:
56
56
  matching.append(s)
57
-
57
+
58
58
  return matching
59
59
 
60
60
  def extract_project_path(self, session_file: Path) -> Optional[Path]:
61
61
  """
62
62
  Extract project path from Gemini session file.
63
-
63
+
64
64
  Gemini CLI JSON files usually don't have the full path, but they might
65
65
  have a projectHash. Wait, let me check the GeminiTrigger logic again.
66
66
  """
@@ -72,5 +72,5 @@ class GeminiAdapter(SessionAdapter):
72
72
  """Check if this is a Gemini session file."""
73
73
  if not session_file.name.startswith("session-") or not session_file.name.endswith(".json"):
74
74
  return False
75
-
75
+
76
76
  return super().is_session_valid(session_file)
@@ -22,7 +22,7 @@ class AdapterRegistry:
22
22
  def register(self, adapter_class: Type[SessionAdapter]):
23
23
  """
24
24
  Register an adapter class.
25
-
25
+
26
26
  Args:
27
27
  adapter_class: The adapter class (not instance)
28
28
  """
@@ -34,11 +34,11 @@ class AdapterRegistry:
34
34
  def get_adapter(self, name: str, config: Optional[Dict] = None) -> Optional[SessionAdapter]:
35
35
  """
36
36
  Get an adapter instance by name.
37
-
37
+
38
38
  Args:
39
39
  name: Adapter name
40
40
  config: Optional configuration
41
-
41
+
42
42
  Returns:
43
43
  Adapter instance or None
44
44
  """
@@ -51,13 +51,15 @@ class AdapterRegistry:
51
51
  """List all registered adapter names."""
52
52
  return list(self._adapters.keys())
53
53
 
54
- def discover_all_sessions(self, config: Optional[Dict] = None) -> List[Tuple[Path, SessionAdapter]]:
54
+ def discover_all_sessions(
55
+ self, config: Optional[Dict] = None
56
+ ) -> List[Tuple[Path, SessionAdapter]]:
55
57
  """
56
58
  Discover sessions from all registered adapters.
57
-
59
+
58
60
  Args:
59
61
  config: Optional configuration (passed to adapters)
60
-
62
+
61
63
  Returns:
62
64
  List of (session_path, adapter_instance) tuples
63
65
  """
@@ -72,17 +74,19 @@ class AdapterRegistry:
72
74
  all_sessions.append((session_path, adapter))
73
75
  except Exception as e:
74
76
  logger.warning(f"Error discovering sessions for adapter {name}: {e}")
75
-
77
+
76
78
  return all_sessions
77
79
 
78
- def auto_detect_adapter(self, session_file: Path, config: Optional[Dict] = None) -> Optional[SessionAdapter]:
80
+ def auto_detect_adapter(
81
+ self, session_file: Path, config: Optional[Dict] = None
82
+ ) -> Optional[SessionAdapter]:
79
83
  """
80
84
  Detect which adapter should handle a given session file.
81
-
85
+
82
86
  Args:
83
87
  session_file: Path to the session file
84
88
  config: Optional configuration
85
-
89
+
86
90
  Returns:
87
91
  Matching Adapter instance or None
88
92
  """
@@ -25,10 +25,10 @@ def get_claude_project_name(project_path: Path) -> str:
25
25
  # Remove leading '/' and replace all '/' and '_' with '-'
26
26
  # Claude Code replaces both slashes and underscores with dashes
27
27
  path_str = str(abs_path)
28
- if path_str.startswith('/'):
28
+ if path_str.startswith("/"):
29
29
  path_str = path_str[1:]
30
30
 
31
- return '-' + path_str.replace('/', '-').replace('_', '-')
31
+ return "-" + path_str.replace("/", "-").replace("_", "-")
32
32
 
33
33
 
34
34
  def find_claude_sessions_dir(project_path: Path) -> Optional[Path]:
@@ -10,7 +10,7 @@ This module contains hooks for integration with AI coding assistants:
10
10
  from pathlib import Path
11
11
 
12
12
  # Signal directory for inter-process communication
13
- SIGNAL_DIR = Path.home() / '.aline' / '.signals'
13
+ SIGNAL_DIR = Path.home() / ".aline" / ".signals"
14
14
 
15
15
 
16
16
  def get_signal_dir() -> Path:
@@ -20,6 +20,6 @@ def get_signal_dir() -> Path:
20
20
 
21
21
 
22
22
  __all__ = [
23
- 'SIGNAL_DIR',
24
- 'get_signal_dir',
23
+ "SIGNAL_DIR",
24
+ "get_signal_dir",
25
25
  ]
@@ -25,7 +25,37 @@ stdin JSON format:
25
25
  import os
26
26
  import sys
27
27
  import json
28
+ import time
28
29
  import subprocess
30
+ from pathlib import Path
31
+
32
+
33
+ def get_signal_dir() -> Path:
34
+ """Get the signal directory for permission requests."""
35
+ signal_dir = Path.home() / ".aline" / ".signals" / "permission_request"
36
+ signal_dir.mkdir(parents=True, exist_ok=True)
37
+ return signal_dir
38
+
39
+
40
+ def write_signal_file(terminal_id: str, tool_name: str = "") -> None:
41
+ """Write a signal file to notify the dashboard of a permission request."""
42
+ try:
43
+ signal_dir = get_signal_dir()
44
+ timestamp_ms = int(time.time() * 1000)
45
+ signal_file = signal_dir / f"{terminal_id}_{timestamp_ms}.signal"
46
+ tmp_file = signal_dir / f"{terminal_id}_{timestamp_ms}.signal.tmp"
47
+
48
+ signal_data = {
49
+ "terminal_id": terminal_id,
50
+ "tool_name": tool_name,
51
+ "timestamp": time.time(),
52
+ "hook_event": "PermissionRequest",
53
+ }
54
+
55
+ tmp_file.write_text(json.dumps(signal_data, indent=2))
56
+ tmp_file.replace(signal_file)
57
+ except Exception:
58
+ pass # Best effort
29
59
 
30
60
 
31
61
  def main():
@@ -142,6 +172,11 @@ def main():
142
172
  except Exception:
143
173
  pass
144
174
 
175
+ # Write signal file to notify dashboard (triggers file watcher refresh)
176
+ if terminal_id:
177
+ tool_name = data.get("tool_name", "")
178
+ write_signal_file(terminal_id, tool_name)
179
+
145
180
  # Exit 0 - don't block the permission request
146
181
  sys.exit(0)
147
182
 
@@ -12,7 +12,7 @@ from typing import Optional
12
12
 
13
13
  from ..logging_config import setup_logger
14
14
 
15
- logger = setup_logger('realign.hooks.permission_request_installer', 'hooks_installer.log')
15
+ logger = setup_logger("realign.hooks.permission_request_installer", "hooks_installer.log")
16
16
 
17
17
  # Marker to identify Aline hook for later uninstallation
18
18
  ALINE_HOOK_MARKER = "aline-permission-request-hook"
@@ -20,7 +20,7 @@ ALINE_HOOK_MARKER = "aline-permission-request-hook"
20
20
 
21
21
  def get_permission_request_hook_script_path() -> Path:
22
22
  """Get path to permission_request_hook.py script"""
23
- return Path(__file__).parent / 'permission_request_hook.py'
23
+ return Path(__file__).parent / "permission_request_hook.py"
24
24
 
25
25
 
26
26
  def get_permission_request_hook_command() -> str:
@@ -44,8 +44,8 @@ def get_settings_path(project_path: Optional[Path] = None) -> Path:
44
44
  Path to settings.json
45
45
  """
46
46
  if project_path:
47
- return project_path / '.claude' / 'settings.local.json'
48
- return Path.home() / '.claude' / 'settings.json'
47
+ return project_path / ".claude" / "settings.local.json"
48
+ return Path.home() / ".claude" / "settings.json"
49
49
 
50
50
 
51
51
  def is_hook_installed(settings_path: Path) -> bool:
@@ -63,13 +63,13 @@ def is_hook_installed(settings_path: Path) -> bool:
63
63
 
64
64
  try:
65
65
  settings = json.loads(settings_path.read_text())
66
- permission_hooks = settings.get('hooks', {}).get('PermissionRequest', [])
66
+ permission_hooks = settings.get("hooks", {}).get("PermissionRequest", [])
67
67
 
68
68
  for hook_config in permission_hooks:
69
- hooks_list = hook_config.get('hooks', [])
69
+ hooks_list = hook_config.get("hooks", [])
70
70
  for h in hooks_list:
71
- command = h.get('command', '')
72
- if ALINE_HOOK_MARKER in command or 'permission_request_hook' in command:
71
+ command = h.get("command", "")
72
+ if ALINE_HOOK_MARKER in command or "permission_request_hook" in command:
73
73
  return True
74
74
 
75
75
  return False
@@ -115,37 +115,35 @@ def install_permission_request_hook(
115
115
  settings = {}
116
116
 
117
117
  # Ensure hooks structure exists
118
- if 'hooks' not in settings:
119
- settings['hooks'] = {}
120
- if 'PermissionRequest' not in settings['hooks']:
121
- settings['hooks']['PermissionRequest'] = []
118
+ if "hooks" not in settings:
119
+ settings["hooks"] = {}
120
+ if "PermissionRequest" not in settings["hooks"]:
121
+ settings["hooks"]["PermissionRequest"] = []
122
122
 
123
123
  # If force install, remove old Aline hook first
124
124
  if force:
125
- permission_hooks = settings['hooks']['PermissionRequest']
125
+ permission_hooks = settings["hooks"]["PermissionRequest"]
126
126
  new_hooks = []
127
127
  for hook_config in permission_hooks:
128
- hooks_list = hook_config.get('hooks', [])
128
+ hooks_list = hook_config.get("hooks", [])
129
129
  filtered = [
130
- h for h in hooks_list
131
- if ALINE_HOOK_MARKER not in h.get('command', '')
132
- and 'permission_request_hook' not in h.get('command', '')
130
+ h
131
+ for h in hooks_list
132
+ if ALINE_HOOK_MARKER not in h.get("command", "")
133
+ and "permission_request_hook" not in h.get("command", "")
133
134
  ]
134
135
  if filtered:
135
- hook_config['hooks'] = filtered
136
+ hook_config["hooks"] = filtered
136
137
  new_hooks.append(hook_config)
137
- settings['hooks']['PermissionRequest'] = new_hooks
138
+ settings["hooks"]["PermissionRequest"] = new_hooks
138
139
 
139
140
  # Append Aline hook with matcher for all tools
140
141
  hook_command = get_permission_request_hook_command()
141
142
  aline_hook = {
142
- 'matcher': '*', # Match all tools
143
- 'hooks': [{
144
- 'type': 'command',
145
- 'command': f'{hook_command} # {ALINE_HOOK_MARKER}'
146
- }]
143
+ "matcher": "*", # Match all tools
144
+ "hooks": [{"type": "command", "command": f"{hook_command} # {ALINE_HOOK_MARKER}"}],
147
145
  }
148
- settings['hooks']['PermissionRequest'].append(aline_hook)
146
+ settings["hooks"]["PermissionRequest"].append(aline_hook)
149
147
 
150
148
  # Write back settings
151
149
  settings_path.parent.mkdir(parents=True, exist_ok=True)
@@ -186,26 +184,27 @@ def uninstall_permission_request_hook(
186
184
  return True
187
185
 
188
186
  settings = json.loads(settings_path.read_text())
189
- permission_hooks = settings.get('hooks', {}).get('PermissionRequest', [])
187
+ permission_hooks = settings.get("hooks", {}).get("PermissionRequest", [])
190
188
 
191
189
  # Filter out Aline hook
192
190
  new_hooks = []
193
191
  removed = False
194
192
  for hook_config in permission_hooks:
195
- hooks_list = hook_config.get('hooks', [])
193
+ hooks_list = hook_config.get("hooks", [])
196
194
  filtered = [
197
- h for h in hooks_list
198
- if ALINE_HOOK_MARKER not in h.get('command', '')
199
- and 'permission_request_hook' not in h.get('command', '')
195
+ h
196
+ for h in hooks_list
197
+ if ALINE_HOOK_MARKER not in h.get("command", "")
198
+ and "permission_request_hook" not in h.get("command", "")
200
199
  ]
201
200
  if len(filtered) < len(hooks_list):
202
201
  removed = True
203
202
  if filtered:
204
- hook_config['hooks'] = filtered
203
+ hook_config["hooks"] = filtered
205
204
  new_hooks.append(hook_config)
206
205
 
207
206
  if removed:
208
- settings['hooks']['PermissionRequest'] = new_hooks
207
+ settings["hooks"]["PermissionRequest"] = new_hooks
209
208
  settings_path.write_text(json.dumps(settings, indent=2))
210
209
  logger.info("Aline PermissionRequest hook uninstalled")
211
210
  if not quiet:
@@ -54,7 +54,10 @@ def main():
54
54
  session = data.get("session") or {}
55
55
  session_id = session.get("id") or data.get("session_id") or data.get("sessionId") or ""
56
56
  transcript_path = (
57
- session.get("transcript_path") or data.get("transcript_path") or data.get("transcriptPath") or ""
57
+ session.get("transcript_path")
58
+ or data.get("transcript_path")
59
+ or data.get("transcriptPath")
60
+ or ""
58
61
  )
59
62
  cwd = data.get("cwd") or ""
60
63
 
@@ -12,7 +12,7 @@ from typing import Optional
12
12
 
13
13
  from ..logging_config import setup_logger
14
14
 
15
- logger = setup_logger('realign.hooks.installer', 'hooks_installer.log')
15
+ logger = setup_logger("realign.hooks.installer", "hooks_installer.log")
16
16
 
17
17
  # 用于标识 Aline hook 的标记,方便后续识别和卸载
18
18
  ALINE_HOOK_MARKER = "aline-stop-hook"
@@ -21,7 +21,7 @@ ALINE_HOOK_MARKER = "aline-stop-hook"
21
21
  def get_stop_hook_script_path() -> Path:
22
22
  """获取 stop_hook.py 脚本的路径"""
23
23
  # 脚本与此文件在同一目录
24
- return Path(__file__).parent / 'stop_hook.py'
24
+ return Path(__file__).parent / "stop_hook.py"
25
25
 
26
26
 
27
27
  def get_stop_hook_command() -> str:
@@ -45,8 +45,8 @@ def get_settings_path(project_path: Optional[Path] = None) -> Path:
45
45
  settings.json 的路径
46
46
  """
47
47
  if project_path:
48
- return project_path / '.claude' / 'settings.local.json'
49
- return Path.home() / '.claude' / 'settings.json'
48
+ return project_path / ".claude" / "settings.local.json"
49
+ return Path.home() / ".claude" / "settings.json"
50
50
 
51
51
 
52
52
  def is_hook_installed(settings_path: Path) -> bool:
@@ -64,13 +64,13 @@ def is_hook_installed(settings_path: Path) -> bool:
64
64
 
65
65
  try:
66
66
  settings = json.loads(settings_path.read_text())
67
- stop_hooks = settings.get('hooks', {}).get('Stop', [])
67
+ stop_hooks = settings.get("hooks", {}).get("Stop", [])
68
68
 
69
69
  for hook_config in stop_hooks:
70
- hooks_list = hook_config.get('hooks', [])
70
+ hooks_list = hook_config.get("hooks", [])
71
71
  for h in hooks_list:
72
- command = h.get('command', '')
73
- if ALINE_HOOK_MARKER in command or 'realign.hooks.stop_hook' in command:
72
+ command = h.get("command", "")
73
+ if ALINE_HOOK_MARKER in command or "realign.hooks.stop_hook" in command:
74
74
  return True
75
75
 
76
76
  return False
@@ -116,36 +116,34 @@ def install_stop_hook(
116
116
  settings = {}
117
117
 
118
118
  # 确保 hooks 结构存在
119
- if 'hooks' not in settings:
120
- settings['hooks'] = {}
121
- if 'Stop' not in settings['hooks']:
122
- settings['hooks']['Stop'] = []
119
+ if "hooks" not in settings:
120
+ settings["hooks"] = {}
121
+ if "Stop" not in settings["hooks"]:
122
+ settings["hooks"]["Stop"] = []
123
123
 
124
124
  # 如果强制安装,先移除旧的 Aline hook
125
125
  if force:
126
- stop_hooks = settings['hooks']['Stop']
126
+ stop_hooks = settings["hooks"]["Stop"]
127
127
  new_hooks = []
128
128
  for hook_config in stop_hooks:
129
- hooks_list = hook_config.get('hooks', [])
129
+ hooks_list = hook_config.get("hooks", [])
130
130
  filtered = [
131
- h for h in hooks_list
132
- if ALINE_HOOK_MARKER not in h.get('command', '')
133
- and 'realign.hooks.stop_hook' not in h.get('command', '')
131
+ h
132
+ for h in hooks_list
133
+ if ALINE_HOOK_MARKER not in h.get("command", "")
134
+ and "realign.hooks.stop_hook" not in h.get("command", "")
134
135
  ]
135
136
  if filtered:
136
- hook_config['hooks'] = filtered
137
+ hook_config["hooks"] = filtered
137
138
  new_hooks.append(hook_config)
138
- settings['hooks']['Stop'] = new_hooks
139
+ settings["hooks"]["Stop"] = new_hooks
139
140
 
140
141
  # 追加 Aline hook
141
142
  hook_command = get_stop_hook_command()
142
143
  aline_hook = {
143
- 'hooks': [{
144
- 'type': 'command',
145
- 'command': f'{hook_command} # {ALINE_HOOK_MARKER}'
146
- }]
144
+ "hooks": [{"type": "command", "command": f"{hook_command} # {ALINE_HOOK_MARKER}"}]
147
145
  }
148
- settings['hooks']['Stop'].append(aline_hook)
146
+ settings["hooks"]["Stop"].append(aline_hook)
149
147
 
150
148
  # 写回设置
151
149
  settings_path.parent.mkdir(parents=True, exist_ok=True)
@@ -186,26 +184,27 @@ def uninstall_stop_hook(
186
184
  return True
187
185
 
188
186
  settings = json.loads(settings_path.read_text())
189
- stop_hooks = settings.get('hooks', {}).get('Stop', [])
187
+ stop_hooks = settings.get("hooks", {}).get("Stop", [])
190
188
 
191
189
  # 过滤掉 Aline hook
192
190
  new_hooks = []
193
191
  removed = False
194
192
  for hook_config in stop_hooks:
195
- hooks_list = hook_config.get('hooks', [])
193
+ hooks_list = hook_config.get("hooks", [])
196
194
  filtered = [
197
- h for h in hooks_list
198
- if ALINE_HOOK_MARKER not in h.get('command', '')
199
- and 'realign.hooks.stop_hook' not in h.get('command', '')
195
+ h
196
+ for h in hooks_list
197
+ if ALINE_HOOK_MARKER not in h.get("command", "")
198
+ and "realign.hooks.stop_hook" not in h.get("command", "")
200
199
  ]
201
200
  if len(filtered) < len(hooks_list):
202
201
  removed = True
203
202
  if filtered:
204
- hook_config['hooks'] = filtered
203
+ hook_config["hooks"] = filtered
205
204
  new_hooks.append(hook_config)
206
205
 
207
206
  if removed:
208
- settings['hooks']['Stop'] = new_hooks
207
+ settings["hooks"]["Stop"] = new_hooks
209
208
  settings_path.write_text(json.dumps(settings, indent=2))
210
209
  logger.info("Aline Stop hook uninstalled")
211
210
  if not quiet:
realign/cli.py CHANGED
@@ -26,6 +26,13 @@ def main(ctx: typer.Context):
26
26
  Run 'aline' without arguments to open the interactive dashboard.
27
27
  """
28
28
  if ctx.invoked_subcommand is None:
29
+ # Check for updates before launching dashboard
30
+ from .commands.upgrade import check_and_prompt_update
31
+
32
+ if check_and_prompt_update():
33
+ # Update was performed, exit so user can restart with new version
34
+ raise typer.Exit(0)
35
+
29
36
  # Launch the dashboard when no subcommand is provided
30
37
  from .dashboard.tmux_manager import bootstrap_dashboard_into_tmux
31
38
 
@@ -157,6 +164,23 @@ def add_skills_cli(
157
164
  raise typer.Exit(code=exit_code)
158
165
 
159
166
 
167
+ @add_app.command(name="skills-dev")
168
+ def add_skills_dev_cli(
169
+ force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing skills"),
170
+ ):
171
+ """Install developer skills from skill-dev/ directory.
172
+
173
+ Scans skill-dev/ for SKILL.md files and installs them to ~/.claude/skills/.
174
+ This is for developer use only.
175
+
176
+ Examples:
177
+ aline add skills-dev # Install dev skills
178
+ aline add skills-dev --force # Reinstall/update dev skills
179
+ """
180
+ exit_code = add.add_skills_dev_command(force=force)
181
+ raise typer.Exit(code=exit_code)
182
+
183
+
160
184
  @context_app.command(name="load")
161
185
  def context_load_cli(
162
186
  sessions: Optional[str] = typer.Option(
realign/codex_detector.py CHANGED
@@ -7,10 +7,7 @@ from pathlib import Path
7
7
  from typing import Optional, List
8
8
 
9
9
 
10
- def find_codex_sessions_for_project(
11
- project_path: Path,
12
- days_back: int = 7
13
- ) -> List[Path]:
10
+ def find_codex_sessions_for_project(project_path: Path, days_back: int = 7) -> List[Path]:
14
11
  """
15
12
  Find Codex sessions for a given project path.
16
13
 
@@ -38,7 +35,12 @@ def find_codex_sessions_for_project(
38
35
  # Search through recent days
39
36
  for days_ago in range(days_back + 1):
40
37
  target_date = datetime.now() - timedelta(days=days_ago)
41
- date_path = codex_sessions_base / str(target_date.year) / f"{target_date.month:02d}" / f"{target_date.day:02d}"
38
+ date_path = (
39
+ codex_sessions_base
40
+ / str(target_date.year)
41
+ / f"{target_date.month:02d}"
42
+ / f"{target_date.day:02d}"
43
+ )
42
44
 
43
45
  if not date_path.exists():
44
46
  continue
@@ -47,12 +49,12 @@ def find_codex_sessions_for_project(
47
49
  for session_file in date_path.glob("rollout-*.jsonl"):
48
50
  try:
49
51
  # Read first line to get session metadata
50
- with open(session_file, 'r', encoding='utf-8') as f:
52
+ with open(session_file, "r", encoding="utf-8") as f:
51
53
  first_line = f.readline()
52
54
  if first_line:
53
55
  data = json.loads(first_line)
54
- if data.get('type') == 'session_meta':
55
- session_cwd = data.get('payload', {}).get('cwd', '')
56
+ if data.get("type") == "session_meta":
57
+ session_cwd = data.get("payload", {}).get("cwd", "")
56
58
  # Match the project path
57
59
  if session_cwd == abs_project_path:
58
60
  matching_sessions.append(session_file)
@@ -93,9 +95,7 @@ def get_codex_sessions_dir() -> Optional[Path]:
93
95
 
94
96
 
95
97
  def auto_detect_codex_sessions(
96
- project_path: Path,
97
- fallback_path: Optional[str] = None,
98
- days_back: int = 7
98
+ project_path: Path, fallback_path: Optional[str] = None, days_back: int = 7
99
99
  ) -> Optional[Path]:
100
100
  """
101
101
  Auto-detect the most recent Codex session for a project.