aline-ai 0.2.6__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.6.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 +115 -411
  27. realign/logging_config.py +2 -2
  28. realign/mcp_server.py +263 -549
  29. realign/mcp_watcher.py +997 -139
  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.6.dist-info/RECORD +0 -28
  38. aline_ai-0.2.6.dist-info/entry_points.txt +0 -5
  39. realign/commands/auto_commit.py +0 -242
  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.6.dist-info → aline_ai-0.3.0.dist-info}/WHEEL +0 -0
  44. {aline_ai-0.2.6.dist-info → aline_ai-0.3.0.dist-info}/licenses/LICENSE +0 -0
  45. {aline_ai-0.2.6.dist-info → aline_ai-0.3.0.dist-info}/top_level.txt +0 -0
realign/mcp_server.py CHANGED
@@ -1,581 +1,305 @@
1
1
  #!/usr/bin/env python3
2
- """Aline MCP Server - AI Agent Chat Session Tracker via Model Context Protocol."""
2
+ """Aline MCP Server - Query shared conversations via Model Context Protocol."""
3
3
 
4
4
  import asyncio
5
+ import hashlib
5
6
  import json
6
- import subprocess
7
7
  import sys
8
- from pathlib import Path
9
8
  from typing import Any, Optional
9
+ from urllib.parse import urlparse
10
+
11
+ try:
12
+ import httpx
13
+ HTTPX_AVAILABLE = True
14
+ except ImportError:
15
+ HTTPX_AVAILABLE = False
10
16
 
11
17
  from mcp.server import Server
12
18
  from mcp.server.stdio import stdio_server
13
19
  from mcp.types import Tool, TextContent
14
20
 
15
- from .config import ReAlignConfig
16
- from .commands import init, search, show, commit
17
- from .mcp_watcher import start_watcher
18
-
19
21
  from . import __version__
20
22
 
21
23
  # Initialize MCP server
22
24
  app = Server("aline")
23
25
 
24
26
 
25
- @app.list_tools()
26
- async def list_tools() -> list[Tool]:
27
- """List available Aline tools."""
28
- return [
29
- Tool(
30
- name="aline_init",
31
- description=(
32
- "Initialize Aline in a directory to enable AI conversation tracking. "
33
- "This command automatically sets up the workspace and configures session tracking. "
34
- "Use this to start tracking AI conversations in your project."
35
- ),
36
- inputSchema={
37
- "type": "object",
38
- "properties": {
39
- "repo_path": {
40
- "type": "string",
41
- "description": "Path to directory to initialize (default: current directory)",
42
- },
43
- },
44
- },
45
- ),
46
- Tool(
47
- name="aline_search",
48
- description=(
49
- "Search through AI agent chat sessions. "
50
- "Finds sessions with matching keywords in session content. "
51
- "Returns session information and optionally full content. "
52
- "Can search in sessions only or include metadata."
53
- ),
54
- inputSchema={
55
- "type": "object",
56
- "properties": {
57
- "keyword": {
58
- "type": "string",
59
- "description": "Keyword to search for in sessions",
60
- },
61
- "repo_path": {
62
- "type": "string",
63
- "description": "Path to workspace (default: current directory)",
64
- },
65
- "show_session": {
66
- "type": "boolean",
67
- "description": "Include full session content in results",
68
- "default": False,
69
- },
70
- "max_results": {
71
- "type": "integer",
72
- "description": "Maximum number of results to return",
73
- "default": 20,
74
- },
75
- "session_only": {
76
- "type": "boolean",
77
- "description": "Search only in session files. Useful for finding topics discussed in historical conversations.",
78
- "default": False,
79
- },
80
- "commits_only": {
81
- "type": "boolean",
82
- "description": "Search only in metadata, not session files.",
83
- "default": False,
84
- },
85
- },
86
- "required": ["keyword"],
87
- },
88
- ),
89
- Tool(
90
- name="aline_show",
91
- description=(
92
- "Display an AI agent chat session. "
93
- "Supports navigation through the session with line ranges. "
94
- "Use aline_search first to find matching lines, then use this tool to view context around those lines."
95
- ),
96
- inputSchema={
97
- "type": "object",
98
- "properties": {
99
- "commit_hash": {
100
- "type": "string",
101
- "description": "Session identifier (full or short)",
102
- },
103
- "repo_path": {
104
- "type": "string",
105
- "description": "Path to workspace (default: current directory)",
106
- },
107
- "session_path": {
108
- "type": "string",
109
- "description": "Direct path to session file (alternative to commit_hash)",
110
- },
111
- "format": {
112
- "type": "string",
113
- "enum": ["pretty", "json", "raw"],
114
- "description": "Output format",
115
- "default": "pretty",
116
- },
117
- "from_line": {
118
- "type": "integer",
119
- "description": "Start from line number (inclusive, 1-indexed)",
120
- },
121
- "to_line": {
122
- "type": "integer",
123
- "description": "End at line number (inclusive, 1-indexed)",
124
- },
125
- "around_line": {
126
- "type": "integer",
127
- "description": "Show lines around this line number (use with context)",
128
- },
129
- "context": {
130
- "type": "integer",
131
- "description": "Number of lines before/after when using around_line (default: 5)",
132
- "default": 5,
133
- },
134
- "first": {
135
- "type": "integer",
136
- "description": "Show only first N lines",
137
- },
138
- "last": {
139
- "type": "integer",
140
- "description": "Show only last N lines",
141
- },
142
- },
143
- },
144
- ),
145
- Tool(
146
- name="aline_get_latest_session",
147
- description=(
148
- "Get the path to the most recent AI agent chat session. "
149
- "Automatically detects Claude Code sessions or uses configured history path. "
150
- "Useful for retrieving the current conversation context."
151
- ),
152
- inputSchema={
153
- "type": "object",
154
- "properties": {
155
- "repo_path": {
156
- "type": "string",
157
- "description": "Path to workspace (for Claude auto-detection)",
158
- },
159
- },
160
- },
161
- ),
162
- Tool(
163
- name="aline_version",
164
- description=(
165
- f"Get the current version of Aline ({__version__}). "
166
- "Aline is a tool that tracks and preserves AI agent chat sessions."
167
- ),
168
- inputSchema={
169
- "type": "object",
170
- "properties": {},
171
- },
172
- ),
173
- Tool(
174
- name="aline_review",
175
- description=(
176
- "Review unpushed commits before pushing. "
177
- "Shows commit messages, LLM summaries, session files modified, and line counts. "
178
- "Helps identify what content will be made public when you push."
179
- ),
180
- inputSchema={
181
- "type": "object",
182
- "properties": {
183
- "repo_path": {
184
- "type": "string",
185
- "description": "Path to repository root (default: current directory)",
186
- },
187
- "verbose": {
188
- "type": "boolean",
189
- "description": "Show detailed information",
190
- "default": False,
191
- },
192
- "detect_secrets": {
193
- "type": "boolean",
194
- "description": "Run sensitive content detection",
195
- "default": False,
196
- },
197
- },
198
- },
199
- ),
200
- Tool(
201
- name="aline_hide",
202
- description=(
203
- "Hide (redact) specific unpushed commits by rewriting git history. "
204
- "This redacts commit messages and session content. "
205
- "WARNING: This rewrites git history. Use with caution."
206
- ),
207
- inputSchema={
208
- "type": "object",
209
- "properties": {
210
- "indices": {
211
- "type": "string",
212
- "description": (
213
- "Commit indices to hide (e.g., '1', '1,3,5', '2-4', or '--all'). "
214
- "Use aline_review first to see the commit indices."
215
- ),
216
- },
217
- "repo_path": {
218
- "type": "string",
219
- "description": "Path to repository root (default: current directory)",
220
- },
221
- "force": {
222
- "type": "boolean",
223
- "description": "Skip confirmation prompt",
224
- "default": False,
225
- },
226
- },
227
- "required": ["indices"],
228
- },
229
- ),
230
- ]
27
+ def extract_share_id(share_url: str) -> str:
28
+ """
29
+ Extract share ID from share URL.
231
30
 
31
+ Examples:
32
+ https://realign-server.vercel.app/share/abc123 -> abc123
33
+ https://example.com/share/xyz789/chat -> xyz789
34
+ """
35
+ # Remove trailing slash
36
+ url = share_url.rstrip('/')
232
37
 
233
- @app.call_tool()
234
- async def call_tool(name: str, arguments: Any) -> list[TextContent]:
235
- """Handle tool execution."""
38
+ # Extract path components
39
+ parsed = urlparse(url)
40
+ path_parts = [p for p in parsed.path.split('/') if p]
236
41
 
237
- try:
238
- # Execute the tool
239
- if name == "aline_init":
240
- result = await handle_init(arguments)
241
- elif name == "aline_search":
242
- result = await handle_search(arguments)
243
- elif name == "aline_show":
244
- result = await handle_show(arguments)
245
- elif name == "aline_get_latest_session":
246
- result = await handle_get_latest_session(arguments)
247
- elif name == "aline_version":
248
- result = await handle_version(arguments)
249
- elif name == "aline_review":
250
- result = await handle_review(arguments)
251
- elif name == "aline_hide":
252
- result = await handle_hide(arguments)
253
- else:
254
- return [TextContent(type="text", text=f"Unknown tool: {name}")]
42
+ # Find 'share' in path and get next component
43
+ if 'share' in path_parts:
44
+ share_idx = path_parts.index('share')
45
+ if share_idx + 1 < len(path_parts):
46
+ return path_parts[share_idx + 1]
255
47
 
256
- return result
48
+ raise ValueError(f"Could not extract share ID from URL: {share_url}")
257
49
 
258
- except Exception as e:
259
- return [TextContent(type="text", text=f"Error: {str(e)}")]
260
-
261
-
262
- async def handle_init(args: dict) -> list[TextContent]:
263
- """Handle aline_init tool."""
264
- from .commands.init import init_repository
265
-
266
- repo_path = args.get("repo_path", ".")
267
-
268
- # Call Python function directly instead of subprocess
269
- # This avoids PATH issues completely
270
- result = init_repository(
271
- repo_path=repo_path,
272
- auto_init_git=True,
273
- skip_commit=False,
274
- )
275
-
276
- # Format output with detailed parameters
277
- if result["success"]:
278
- output_lines = [
279
- "✅ Aline initialized successfully\n",
280
- "Parameters:",
281
- f" • Workspace Path: {result.get('repo_path', 'N/A')}",
282
- f" • Workspace Root: {result.get('repo_root', 'N/A')}",
283
- f" • Aline Directory: {result.get('realign_dir', 'N/A')}",
284
- f" • Config Path: {result.get('config_path', 'N/A')}",
285
- f" • History Directory: {result.get('history_dir', 'N/A')}",
286
- f" • Initialized: {result.get('git_initialized', False)}",
287
- f" • Hooks Created: {', '.join(result.get('hooks_created', [])) or 'None'}",
288
- f" • Auto-committed: {result.get('committed', False)}",
289
- "",
290
- "Aline is now tracking your AI conversations automatically.",
291
- "Use aline_search to find past discussions.",
292
- ]
293
- return [TextContent(type="text", text="\n".join(output_lines))]
294
- else:
295
- error_lines = [
296
- "❌ Failed to initialize Aline\n",
297
- f"Error: {result['message']}",
298
- ]
299
- if result.get("errors"):
300
- error_lines.append("\nDetails:")
301
- for error in result["errors"]:
302
- error_lines.append(f" • {error}")
303
- return [TextContent(type="text", text="\n".join(error_lines))]
304
-
305
-
306
- async def handle_search(args: dict) -> list[TextContent]:
307
- """Handle aline_search tool."""
308
- keyword = args["keyword"]
309
- repo_path = args.get("repo_path", ".")
310
- show_session = args.get("show_session", False)
311
- max_results = args.get("max_results", 20)
312
- session_only = args.get("session_only", False)
313
- commits_only = args.get("commits_only", False)
314
-
315
- # Build command
316
- cmd = ["aline", "search", keyword, "--max", str(max_results)]
317
- if show_session:
318
- cmd.append("--show-session")
319
- if session_only:
320
- cmd.append("--session-only")
321
- if commits_only:
322
- cmd.append("--commits-only")
323
-
324
- # Run command
325
- result = subprocess.run(
326
- cmd,
327
- cwd=repo_path,
328
- capture_output=True,
329
- text=True,
330
- )
331
-
332
- output = result.stdout
333
- if result.returncode == 0:
334
- return [TextContent(type="text", text=output or "No results found")]
335
- else:
336
- return [TextContent(
337
- type="text",
338
- text=f"Error searching: {result.stderr}"
339
- )]
340
50
 
51
+ def extract_base_url(share_url: str) -> str:
52
+ """
53
+ Extract base URL from share URL.
341
54
 
342
- async def handle_show(args: dict) -> list[TextContent]:
343
- """Handle aline_show tool."""
344
- commit_hash = args.get("commit_hash")
345
- repo_path = args.get("repo_path", ".")
346
- session_path = args.get("session_path")
347
- format_type = args.get("format", "pretty")
348
- from_line = args.get("from_line")
349
- to_line = args.get("to_line")
350
- around_line = args.get("around_line")
351
- context = args.get("context", 5)
352
- first = args.get("first")
353
- last = args.get("last")
354
-
355
- # Build command
356
- cmd = ["aline", "show"]
357
- if session_path:
358
- cmd.extend(["--session", session_path])
359
- elif commit_hash:
360
- cmd.append(commit_hash)
361
- else:
362
- return [TextContent(
363
- type="text",
364
- text="Error: Must provide either commit_hash or session_path"
365
- )]
55
+ Examples:
56
+ https://realign-server.vercel.app/share/abc123 -> https://realign-server.vercel.app
57
+ """
58
+ parsed = urlparse(share_url)
59
+ return f"{parsed.scheme}://{parsed.netloc}"
366
60
 
367
- cmd.extend(["--format", format_type])
368
-
369
- # Add range parameters
370
- if from_line is not None:
371
- cmd.extend(["--from", str(from_line)])
372
- if to_line is not None:
373
- cmd.extend(["--to", str(to_line)])
374
- if around_line is not None:
375
- cmd.extend(["--around", str(around_line)])
376
- if context != 5: # Only add if not default
377
- cmd.extend(["--context", str(context)])
378
- if first is not None:
379
- cmd.extend(["--first", str(first)])
380
- if last is not None:
381
- cmd.extend(["--last", str(last)])
382
-
383
- # Run command
384
- result = subprocess.run(
385
- cmd,
386
- cwd=repo_path,
387
- capture_output=True,
388
- text=True,
389
- )
390
-
391
- if result.returncode == 0:
392
- return [TextContent(type="text", text=result.stdout)]
393
- else:
394
- return [TextContent(
395
- type="text",
396
- text=f"Error showing session: {result.stderr}"
397
- )]
398
61
 
62
+ async def authenticate_share(share_url: str, password: Optional[str] = None) -> tuple[str, str, str]:
63
+ """
64
+ Authenticate with share and get session token.
65
+
66
+ Args:
67
+ share_url: The share URL
68
+ password: Optional password for encrypted shares
69
+
70
+ Returns:
71
+ tuple of (base_url, share_id, session_token)
72
+
73
+ Raises:
74
+ ValueError: If authentication fails
75
+ httpx.HTTPError: If network request fails
76
+ """
77
+ if not HTTPX_AVAILABLE:
78
+ raise RuntimeError("httpx package is required. Install with: pip install httpx")
79
+
80
+ share_id = extract_share_id(share_url)
81
+ base_url = extract_base_url(share_url)
399
82
 
400
- async def handle_get_latest_session(args: dict) -> list[TextContent]:
401
- """Handle aline_get_latest_session tool."""
402
- from .claude_detector import find_claude_sessions_dir
83
+ async with httpx.AsyncClient(timeout=30.0) as client:
84
+ # Step 1: Check if password is required
85
+ info_resp = await client.get(f"{base_url}/api/share/{share_id}/info")
86
+ info_resp.raise_for_status()
87
+ info = info_resp.json()
403
88
 
404
- repo_path = args.get("repo_path", ".")
405
- config = ReAlignConfig.load()
89
+ requires_password = info.get("requires_password", False)
406
90
 
407
- # Try Claude auto-detection first
408
- if config.auto_detect_claude:
409
- claude_dir = find_claude_sessions_dir(Path(repo_path))
410
- if claude_dir:
411
- # Find latest session in Claude directory
412
- session_files = sorted(
413
- claude_dir.glob("*.jsonl"),
414
- key=lambda p: p.stat().st_mtime,
415
- reverse=True,
91
+ # Step 2: Authenticate
92
+ if requires_password:
93
+ if not password:
94
+ raise ValueError("This share requires a password, but none was provided")
95
+
96
+ # Hash password (SHA-256)
97
+ password_hash = hashlib.sha256(password.encode()).hexdigest()
98
+
99
+ auth_resp = await client.post(
100
+ f"{base_url}/api/share/{share_id}/auth",
101
+ json={"password_hash": password_hash}
102
+ )
103
+ auth_resp.raise_for_status()
104
+ data = auth_resp.json()
105
+ else:
106
+ # No password needed - create session directly
107
+ session_resp = await client.post(
108
+ f"{base_url}/api/share/{share_id}/session"
416
109
  )
417
- if session_files:
418
- return [TextContent(
419
- type="text",
420
- text=f"Latest Claude Code session: {session_files[0]}"
421
- )]
110
+ session_resp.raise_for_status()
111
+ data = session_resp.json()
112
+
113
+ session_token = data.get("session_token")
114
+ if not session_token:
115
+ raise ValueError("Failed to obtain session token")
116
+
117
+ return base_url, share_id, session_token
118
+
119
+
120
+ async def ask_conversation(
121
+ base_url: str,
122
+ share_id: str,
123
+ session_token: str,
124
+ question: str
125
+ ) -> str:
126
+ """
127
+ Send a question to the remote AI agent and receive the answer.
128
+
129
+ Args:
130
+ base_url: Base URL of the share service
131
+ share_id: Share identifier
132
+ session_token: Session token from authentication
133
+ question: The question to ask
134
+
135
+ Returns:
136
+ The answer from the remote AI agent
137
+
138
+ Raises:
139
+ httpx.HTTPError: If network request fails
140
+ """
141
+ if not HTTPX_AVAILABLE:
142
+ raise RuntimeError("httpx package is required. Install with: pip install httpx")
143
+
144
+ async with httpx.AsyncClient(timeout=120.0) as client:
145
+ # Send question to chat API with proper UIMessage format
146
+ # UIMessage requires 'parts' instead of 'content'
147
+ resp = await client.post(
148
+ f"{base_url}/api/chat/{share_id}",
149
+ headers={"x-session-token": session_token},
150
+ json={"messages": [{"role": "user", "parts": [{"type": "text", "text": question}]}]}
151
+ )
152
+ resp.raise_for_status()
153
+
154
+ # Parse streaming response from Vercel AI SDK
155
+ # The AI SDK returns a UIMessageStreamResponse with multiple data chunks
156
+ # We only need to extract the final text content
157
+ text_chunks = []
158
+
159
+ async for chunk in resp.aiter_text():
160
+ # Split into lines and process each
161
+ for line in chunk.split('\n'):
162
+ line = line.strip()
163
+ if not line:
164
+ continue
165
+
166
+ # Remove "data: " prefix if present
167
+ if line.startswith('data: '):
168
+ line = line[6:]
169
+
170
+ # Try to parse as JSON
171
+ try:
172
+ data = json.loads(line)
173
+
174
+ # Vercel AI SDK sends different types of chunks
175
+ # We're looking for text deltas (type: "text-delta")
176
+ if isinstance(data, dict):
177
+ # Extract text from text-delta chunks
178
+ # The field name is 'delta' not 'textDelta'
179
+ if data.get('type') == 'text-delta':
180
+ delta = data.get('delta', '')
181
+ if delta:
182
+ text_chunks.append(delta)
183
+
184
+ except json.JSONDecodeError:
185
+ # Not JSON, skip
186
+ continue
187
+
188
+ # Combine all text chunks to get the final answer
189
+ answer = ''.join(text_chunks)
190
+
191
+ # Apply reasonable length limit to prevent overwhelming the MCP client
192
+ # If answer is too long, truncate and add notice
193
+ MAX_RESPONSE_LENGTH = 50000 # ~50KB of text
194
+ if len(answer) > MAX_RESPONSE_LENGTH:
195
+ answer = answer[:MAX_RESPONSE_LENGTH] + "\n\n[Response truncated due to length. Please ask more specific questions to get complete answers.]"
196
+
197
+ return answer if answer else "No response received from the agent."
198
+
199
+
200
+ async def handle_ask_tool(
201
+ share_url: str,
202
+ question: str,
203
+ password: Optional[str] = None
204
+ ) -> list[TextContent]:
205
+ """
206
+ Handle the ask_shared_conversation tool.
207
+
208
+ Args:
209
+ share_url: URL of the shared conversation
210
+ question: Question to ask
211
+ password: Optional password for encrypted shares
212
+
213
+ Returns:
214
+ List of TextContent with the answer or error
215
+ """
216
+ try:
217
+ # Authenticate and get session token
218
+ base_url, share_id, token = await authenticate_share(share_url, password)
422
219
 
423
- # Fallback to configured history path
424
- history_path = config.expanded_local_history_path
220
+ # Ask the question
221
+ answer = await ask_conversation(base_url, share_id, token, question)
425
222
 
426
- if not history_path.exists():
223
+ # Return the answer
427
224
  return [TextContent(
428
225
  type="text",
429
- text=f"No sessions found. History path does not exist: {history_path}"
226
+ text=answer
430
227
  )]
431
228
 
432
- # Find latest session file
433
- session_files = sorted(
434
- history_path.glob("*.jsonl"),
435
- key=lambda p: p.stat().st_mtime,
436
- reverse=True,
437
- )
438
-
439
- if session_files:
229
+ except ValueError as e:
440
230
  return [TextContent(
441
231
  type="text",
442
- text=f"Latest session: {session_files[0]}"
232
+ text=f"Authentication error: {str(e)}"
443
233
  )]
444
- else:
234
+ except Exception as e:
445
235
  return [TextContent(
446
236
  type="text",
447
- text=f"No session files found in {history_path}"
237
+ text=f"Error querying shared conversation: {str(e)}"
448
238
  )]
449
239
 
450
240
 
451
- async def handle_version(args: dict) -> list[TextContent]:
452
- """Handle aline_version tool."""
453
- return [TextContent(
454
- type="text",
455
- text=f"Aline version {__version__}"
456
- )]
457
-
458
-
459
- async def handle_review(args: dict) -> list[TextContent]:
460
- """Handle aline_review tool."""
461
- from .commands.review import review_command
462
- from io import StringIO
463
- import sys
464
-
465
- repo_path = args.get("repo_path")
466
- verbose = args.get("verbose", False)
467
- detect_secrets = args.get("detect_secrets", False)
468
-
469
- # Convert repo_path to Path if provided
470
- repo_root = Path(repo_path) if repo_path else None
471
-
472
- # Capture stdout
473
- old_stdout = sys.stdout
474
- sys.stdout = captured_output = StringIO()
475
-
476
- try:
477
- # Call the review command
478
- exit_code = review_command(
479
- repo_root=repo_root,
480
- verbose=verbose,
481
- detect_secrets=detect_secrets
482
- )
483
-
484
- # Get the output
485
- output = captured_output.getvalue()
486
-
487
- if exit_code == 0:
488
- return [TextContent(type="text", text=output or "No unpushed commits found.")]
489
- else:
490
- return [TextContent(type="text", text=f"Error: Review failed\n{output}")]
491
- finally:
492
- sys.stdout = old_stdout
493
-
494
-
495
- async def handle_hide(args: dict) -> list[TextContent]:
496
- """Handle aline_hide tool."""
497
- from .commands.hide import hide_command
498
- from io import StringIO
499
- import sys
500
-
501
- indices = args["indices"]
502
- repo_path = args.get("repo_path")
503
- force = args.get("force", False)
504
-
505
- # Convert repo_path to Path if provided
506
- repo_root = Path(repo_path) if repo_path else None
241
+ @app.list_tools()
242
+ async def list_tools() -> list[Tool]:
243
+ """List available tools."""
244
+ return [
245
+ Tool(
246
+ name="ask_shared_conversation",
247
+ description=(
248
+ "Ask a question to a shared conversation. The remote AI agent will "
249
+ "search through the conversation history and provide an answer. "
250
+ "This enables agent-to-agent communication where your local agent "
251
+ "can query information from a remote conversation share."
252
+ ),
253
+ inputSchema={
254
+ "type": "object",
255
+ "properties": {
256
+ "share_url": {
257
+ "type": "string",
258
+ "description": (
259
+ "The full URL of the shared conversation "
260
+ "(e.g., https://realign-server.vercel.app/share/abc123xyz)"
261
+ ),
262
+ },
263
+ "question": {
264
+ "type": "string",
265
+ "description": (
266
+ "The question to ask about the conversation. "
267
+ "Be specific to get better answers from the remote agent."
268
+ ),
269
+ },
270
+ "password": {
271
+ "type": "string",
272
+ "description": (
273
+ "Password for encrypted shares (optional). "
274
+ "Leave empty for public shares."
275
+ ),
276
+ },
277
+ },
278
+ "required": ["share_url", "question"],
279
+ },
280
+ ),
281
+ ]
507
282
 
508
- # Capture stdout
509
- old_stdout = sys.stdout
510
- sys.stdout = captured_output = StringIO()
511
283
 
512
- try:
513
- # Call the hide command
514
- exit_code = hide_command(
515
- indices=indices,
516
- repo_root=repo_root,
517
- force=force
284
+ @app.call_tool()
285
+ async def call_tool(name: str, arguments: Any) -> list[TextContent]:
286
+ """Handle tool execution."""
287
+ if name == "ask_shared_conversation":
288
+ return await handle_ask_tool(
289
+ share_url=arguments.get("share_url", ""),
290
+ question=arguments.get("question", ""),
291
+ password=arguments.get("password")
518
292
  )
519
-
520
- # Get the output
521
- output = captured_output.getvalue()
522
-
523
- if exit_code == 0:
524
- return [TextContent(type="text", text=output or "Hide operation completed.")]
525
- else:
526
- return [TextContent(type="text", text=f"Error: Hide failed\n{output}")]
527
- finally:
528
- sys.stdout = old_stdout
529
-
530
-
531
- def _server_log(msg: str):
532
- """Log MCP server messages to both stderr and file."""
533
- from datetime import datetime
534
- timestamp = datetime.now().strftime("%H:%M:%S")
535
- print(f"[MCP Server] {msg}", file=sys.stderr)
536
-
537
- # Also write to the watcher log file for consistency
538
- log_path = Path.home() / ".aline_watcher.log"
539
- try:
540
- with open(log_path, "a") as f:
541
- f.write(f"[{timestamp}] [MCP Server] {msg}\n")
542
- except Exception:
543
- pass
544
-
545
-
546
- # Log immediately when module is imported
547
- _early_log_path = Path.home() / ".aline_mcp_startup.log"
548
- try:
549
- with open(_early_log_path, "a") as f:
550
- from datetime import datetime
551
- f.write(f"\n{'='*60}\n")
552
- f.write(f"[{datetime.now()}] MCP SERVER MODULE LOADED\n")
553
- f.write(f"Python: {sys.executable}\n")
554
- f.write(f"CWD: {Path.cwd()}\n")
555
- f.write(f"HOME: {Path.home()}\n")
556
- f.write(f"Version: {__version__}\n")
557
- f.write(f"{'='*60}\n")
558
- except Exception as e:
559
- print(f"[MCP Server] Early log failed: {e}", file=sys.stderr)
293
+ else:
294
+ return [TextContent(
295
+ type="text",
296
+ text=f"Unknown tool: {name}"
297
+ )]
560
298
 
561
299
 
562
300
  async def async_main():
563
- """Run the MCP server (async)."""
564
- _server_log("Starting Aline MCP server...")
565
- _server_log(f"Current working directory: {Path.cwd()}")
566
- _server_log(f"Home directory: {Path.home()}")
567
-
568
- # Start the watcher (no repo_path needed - it will extract dynamically from sessions)
569
- try:
570
- await start_watcher()
571
- print("[MCP Server] Watcher started (will auto-detect project paths from sessions)", file=sys.stderr)
572
- except Exception as e:
573
- print(f"[MCP Server] Warning: Could not start watcher: {e}", file=sys.stderr)
574
- import traceback
575
- traceback.print_exc(file=sys.stderr)
576
-
577
- _server_log("MCP server ready")
578
-
301
+ """Main async entry point for the MCP server."""
302
+ # Start stdio server
579
303
  async with stdio_server() as (read_stream, write_stream):
580
304
  await app.run(
581
305
  read_stream,
@@ -585,28 +309,18 @@ async def async_main():
585
309
 
586
310
 
587
311
  def main():
588
- """Entry point for the MCP server command."""
589
- # Log entry point
590
- try:
591
- with open(_early_log_path, "a") as f:
592
- from datetime import datetime
593
- f.write(f"[{datetime.now()}] main() called, starting asyncio.run\n")
594
- except Exception:
595
- pass
312
+ """Entry point for the aline-mcp command."""
313
+ # Check if httpx is available
314
+ if not HTTPX_AVAILABLE:
315
+ print(
316
+ "Error: httpx package is required for aline-mcp.\n"
317
+ "Install with: pip install httpx",
318
+ file=sys.stderr
319
+ )
320
+ sys.exit(1)
596
321
 
597
- try:
598
- asyncio.run(async_main())
599
- except Exception as e:
600
- # Log any exceptions
601
- try:
602
- with open(_early_log_path, "a") as f:
603
- from datetime import datetime
604
- import traceback
605
- f.write(f"[{datetime.now()}] EXCEPTION in main: {e}\n")
606
- f.write(traceback.format_exc())
607
- except Exception:
608
- pass
609
- raise
322
+ # Run the async main
323
+ asyncio.run(async_main())
610
324
 
611
325
 
612
326
  if __name__ == "__main__":