htmlgraph 0.27.7__py3-none-any.whl → 0.28.1__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 (34) hide show
  1. htmlgraph/__init__.py +1 -1
  2. htmlgraph/api/broadcast.py +316 -0
  3. htmlgraph/api/broadcast_routes.py +357 -0
  4. htmlgraph/api/broadcast_websocket.py +115 -0
  5. htmlgraph/api/cost_alerts_websocket.py +7 -16
  6. htmlgraph/api/main.py +135 -1
  7. htmlgraph/api/offline.py +776 -0
  8. htmlgraph/api/presence.py +446 -0
  9. htmlgraph/api/reactive.py +455 -0
  10. htmlgraph/api/reactive_routes.py +195 -0
  11. htmlgraph/api/static/broadcast-demo.html +393 -0
  12. htmlgraph/api/static/presence-widget-demo.html +785 -0
  13. htmlgraph/api/sync_routes.py +184 -0
  14. htmlgraph/api/templates/partials/agents.html +308 -80
  15. htmlgraph/api/websocket.py +112 -37
  16. htmlgraph/broadcast_integration.py +227 -0
  17. htmlgraph/cli_commands/sync.py +207 -0
  18. htmlgraph/db/schema.py +226 -0
  19. htmlgraph/hooks/event_tracker.py +53 -2
  20. htmlgraph/models.py +1 -0
  21. htmlgraph/reactive_integration.py +148 -0
  22. htmlgraph/session_manager.py +7 -0
  23. htmlgraph/sync/__init__.py +21 -0
  24. htmlgraph/sync/git_sync.py +458 -0
  25. {htmlgraph-0.27.7.dist-info → htmlgraph-0.28.1.dist-info}/METADATA +1 -1
  26. {htmlgraph-0.27.7.dist-info → htmlgraph-0.28.1.dist-info}/RECORD +32 -19
  27. htmlgraph/dashboard.html +0 -6592
  28. htmlgraph-0.27.7.data/data/htmlgraph/dashboard.html +0 -6592
  29. {htmlgraph-0.27.7.data → htmlgraph-0.28.1.data}/data/htmlgraph/styles.css +0 -0
  30. {htmlgraph-0.27.7.data → htmlgraph-0.28.1.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  31. {htmlgraph-0.27.7.data → htmlgraph-0.28.1.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  32. {htmlgraph-0.27.7.data → htmlgraph-0.28.1.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  33. {htmlgraph-0.27.7.dist-info → htmlgraph-0.28.1.dist-info}/WHEEL +0 -0
  34. {htmlgraph-0.27.7.dist-info → htmlgraph-0.28.1.dist-info}/entry_points.txt +0 -0
@@ -90,6 +90,7 @@ class WebSocketClient:
90
90
 
91
91
  websocket: WebSocket
92
92
  client_id: str
93
+ session_id: str # Session this client belongs to
93
94
  subscription_filter: EventSubscriptionFilter
94
95
  connected_at: datetime = field(default_factory=datetime.now)
95
96
  events_sent: int = 0
@@ -189,8 +190,9 @@ class WebSocketManager:
189
190
  self.event_batch_window_ms = event_batch_window_ms
190
191
  self.poll_interval_ms = poll_interval_ms / 1000.0 # Convert to seconds
191
192
 
192
- # Active connections: {session_id: {client_id: WebSocketClient}}
193
- self.connections: dict[str, dict[str, WebSocketClient]] = {}
193
+ # Active connections: {client_id: WebSocketClient}
194
+ # Flattened structure enables efficient cross-session broadcasting
195
+ self.connections: dict[str, WebSocketClient] = {}
194
196
 
195
197
  # Event batchers per session: {session_id: EventBatcher}
196
198
  self.batchers: dict[str, EventBatcher] = {}
@@ -227,7 +229,7 @@ class WebSocketManager:
227
229
  await websocket.accept()
228
230
 
229
231
  # Check max clients per session
230
- session_clients = self.connections.get(session_id, {})
232
+ session_clients = await self.get_session_clients(session_id)
231
233
  if len(session_clients) >= self.max_clients_per_session:
232
234
  logger.warning(
233
235
  f"Session {session_id} has max clients ({self.max_clients_per_session})"
@@ -243,13 +245,12 @@ class WebSocketManager:
243
245
  client = WebSocketClient(
244
246
  websocket=websocket,
245
247
  client_id=client_id,
248
+ session_id=session_id,
246
249
  subscription_filter=subscription_filter,
247
250
  )
248
251
 
249
- # Add to connections
250
- if session_id not in self.connections:
251
- self.connections[session_id] = {}
252
- self.connections[session_id][client_id] = client
252
+ # Add to flat connections structure
253
+ self.connections[client_id] = client
253
254
 
254
255
  # Create batcher for session if needed
255
256
  if session_id not in self.batchers:
@@ -260,7 +261,8 @@ class WebSocketManager:
260
261
 
261
262
  # Update metrics
262
263
  self.metrics["total_connections"] += 1
263
- self.metrics["active_sessions"] = len(self.connections)
264
+ active_sessions = await self.get_active_sessions()
265
+ self.metrics["active_sessions"] = len(active_sessions)
264
266
 
265
267
  logger.info(
266
268
  f"WebSocket client connected: session={session_id}, client={client_id}"
@@ -279,24 +281,48 @@ class WebSocketManager:
279
281
  session_id: Session ID
280
282
  client_id: Client ID to disconnect
281
283
  """
282
- if session_id not in self.connections:
284
+ if client_id not in self.connections:
283
285
  return
284
286
 
285
- if client_id in self.connections[session_id]:
286
- client = self.connections[session_id][client_id]
287
- del self.connections[session_id][client_id]
287
+ client = self.connections[client_id]
288
+ del self.connections[client_id]
288
289
 
289
- # Update metrics
290
- if not self.connections[session_id]:
291
- del self.connections[session_id]
292
- if session_id in self.batchers:
293
- del self.batchers[session_id]
290
+ # Check if this was the last client for the session
291
+ session_clients = await self.get_session_clients(session_id)
292
+ if not session_clients:
293
+ # Clean up session batcher
294
+ if session_id in self.batchers:
295
+ del self.batchers[session_id]
294
296
 
295
- self.metrics["active_sessions"] = len(self.connections)
296
- logger.info(
297
- f"WebSocket client disconnected: session={session_id}, client={client_id}, "
298
- f"events_sent={client.events_sent}"
299
- )
297
+ # Update metrics
298
+ active_sessions = await self.get_active_sessions()
299
+ self.metrics["active_sessions"] = len(active_sessions)
300
+
301
+ logger.info(
302
+ f"WebSocket client disconnected: session={session_id}, client={client_id}, "
303
+ f"events_sent={client.events_sent}"
304
+ )
305
+
306
+ async def get_session_clients(self, session_id: str) -> list[WebSocketClient]:
307
+ """
308
+ Get all clients for a specific session.
309
+
310
+ Args:
311
+ session_id: Session ID to filter by
312
+
313
+ Returns:
314
+ List of clients belonging to the session
315
+ """
316
+ return [c for c in self.connections.values() if c.session_id == session_id]
317
+
318
+ async def get_active_sessions(self) -> set[str]:
319
+ """
320
+ Get all active session IDs.
321
+
322
+ Returns:
323
+ Set of session IDs with active connections
324
+ """
325
+ return {c.session_id for c in self.connections.values()}
300
326
 
301
327
  async def stream_events(
302
328
  self,
@@ -318,14 +344,11 @@ class WebSocketManager:
318
344
  client_id: Client ID
319
345
  get_db: Async function to get database connection
320
346
  """
321
- if (
322
- session_id not in self.connections
323
- or client_id not in self.connections[session_id]
324
- ):
325
- logger.warning(f"Client not found: {session_id}/{client_id}")
347
+ if client_id not in self.connections:
348
+ logger.warning(f"Client not found: {client_id}")
326
349
  return
327
350
 
328
- client = self.connections[session_id][client_id]
351
+ client = self.connections[client_id]
329
352
  last_timestamp = datetime.now().isoformat()
330
353
  poll_interval = self.poll_interval_ms
331
354
  consecutive_empty_polls = 0
@@ -487,11 +510,21 @@ class WebSocketManager:
487
510
  Returns:
488
511
  Number of clients that received the event
489
512
  """
490
- if session_id not in self.connections:
491
- return 0
513
+ return await self.broadcast_to_session(session_id, event)
514
+
515
+ async def broadcast_to_session(self, session_id: str, event: dict[str, Any]) -> int:
516
+ """
517
+ Broadcast event to all clients in a specific session.
518
+
519
+ Args:
520
+ session_id: Session to broadcast to
521
+ event: Event data
492
522
 
523
+ Returns:
524
+ Number of clients that received the event
525
+ """
493
526
  sent_count = 0
494
- session_clients = list(self.connections[session_id].values())
527
+ session_clients = await self.get_session_clients(session_id)
495
528
 
496
529
  for client in session_clients:
497
530
  if client.subscription_filter.matches_event(event):
@@ -510,12 +543,56 @@ class WebSocketManager:
510
543
 
511
544
  return sent_count
512
545
 
546
+ async def broadcast_to_all_sessions(self, event: dict[str, Any]) -> int:
547
+ """
548
+ Broadcast event to all connected clients across all sessions.
549
+
550
+ Enables cross-session features like:
551
+ - Global notifications
552
+ - System-wide alerts
553
+ - Cross-agent coordination
554
+
555
+ Args:
556
+ event: Event data to broadcast
557
+
558
+ Returns:
559
+ Number of clients that received the event
560
+ """
561
+ sent_count = 0
562
+
563
+ for client in list(self.connections.values()):
564
+ if client.subscription_filter.matches_event(event):
565
+ try:
566
+ await client.websocket.send_json(
567
+ {
568
+ "type": "event",
569
+ "timestamp": datetime.now().isoformat(),
570
+ **event,
571
+ }
572
+ )
573
+ sent_count += 1
574
+ client.events_sent += 1
575
+ except Exception as e:
576
+ logger.error(f"Broadcast error to {client.client_id}: {e}")
577
+
578
+ return sent_count
579
+
513
580
  def get_session_metrics(self, session_id: str) -> dict[str, Any]:
514
581
  """Get metrics for a session."""
515
- if session_id not in self.connections:
582
+ # Use asyncio.run to call async method in sync context
583
+ import asyncio
584
+
585
+ try:
586
+ loop = asyncio.get_event_loop()
587
+ except RuntimeError:
588
+ loop = asyncio.new_event_loop()
589
+ asyncio.set_event_loop(loop)
590
+
591
+ clients = loop.run_until_complete(self.get_session_clients(session_id))
592
+
593
+ if not clients:
516
594
  return {}
517
595
 
518
- clients = self.connections[session_id].values()
519
596
  return {
520
597
  "session_id": session_id,
521
598
  "connected_clients": len(clients),
@@ -531,8 +608,6 @@ class WebSocketManager:
531
608
  """Get global WebSocket metrics."""
532
609
  return {
533
610
  **self.metrics,
534
- "active_sessions": len(self.connections),
535
- "total_connected_clients": sum(
536
- len(clients) for clients in self.connections.values()
537
- ),
611
+ "active_sessions": self.metrics["active_sessions"],
612
+ "total_connected_clients": len(self.connections),
538
613
  }
@@ -0,0 +1,227 @@
1
+ """
2
+ Broadcast Integration Helper - SDK to Broadcast Bridge
3
+
4
+ Provides helper functions to integrate broadcasting with SDK operations.
5
+ Handles the bridge between synchronous SDK calls and async broadcast operations.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ from typing import Any
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def get_or_create_event_loop() -> asyncio.AbstractEventLoop:
16
+ """Get existing event loop or create new one if none exists."""
17
+ try:
18
+ return asyncio.get_running_loop()
19
+ except RuntimeError:
20
+ # No event loop running, create a new one
21
+ try:
22
+ loop = asyncio.get_event_loop()
23
+ if loop.is_closed():
24
+ loop = asyncio.new_event_loop()
25
+ asyncio.set_event_loop(loop)
26
+ return loop
27
+ except RuntimeError:
28
+ loop = asyncio.new_event_loop()
29
+ asyncio.set_event_loop(loop)
30
+ return loop
31
+
32
+
33
+ def broadcast_feature_save(
34
+ feature_id: str,
35
+ agent_id: str,
36
+ session_id: str,
37
+ payload: dict[str, Any],
38
+ is_new: bool = False,
39
+ ) -> None:
40
+ """
41
+ Broadcast feature save operation to all sessions.
42
+
43
+ This is a synchronous wrapper that can be called from SDK save() methods.
44
+ It handles async event loop creation and broadcasting.
45
+
46
+ Args:
47
+ feature_id: Feature being saved
48
+ agent_id: Agent making the change
49
+ session_id: Source session ID
50
+ payload: Feature data (title, status, description, etc.)
51
+ is_new: True if creating new feature, False if updating
52
+ """
53
+ try:
54
+ # Import here to avoid circular dependencies
55
+ from pathlib import Path
56
+
57
+ from htmlgraph.api.broadcast import CrossSessionBroadcaster
58
+ from htmlgraph.api.websocket import WebSocketManager
59
+
60
+ # Get database path
61
+ db_path = str(Path.home() / ".htmlgraph" / "htmlgraph.db")
62
+
63
+ # Create WebSocketManager and Broadcaster instances
64
+ # Note: These are lightweight, stateless for broadcasting
65
+ websocket_manager = WebSocketManager(db_path)
66
+ broadcaster = CrossSessionBroadcaster(websocket_manager, db_path)
67
+
68
+ # Determine event type
69
+ event_type = "created" if is_new else "updated"
70
+ payload["event_type"] = event_type
71
+
72
+ # Run async broadcast in event loop
73
+ loop = get_or_create_event_loop()
74
+
75
+ if loop.is_running():
76
+ # We're already in an async context, schedule task
77
+ asyncio.create_task(
78
+ broadcaster.broadcast_feature_update(
79
+ feature_id=feature_id,
80
+ agent_id=agent_id,
81
+ session_id=session_id,
82
+ payload=payload,
83
+ )
84
+ )
85
+ # Don't wait for completion to avoid blocking
86
+ logger.debug(
87
+ f"Scheduled broadcast task for feature {feature_id} (async context)"
88
+ )
89
+ else:
90
+ # Not in async context, run until complete
91
+ clients_notified = loop.run_until_complete(
92
+ broadcaster.broadcast_feature_update(
93
+ feature_id=feature_id,
94
+ agent_id=agent_id,
95
+ session_id=session_id,
96
+ payload=payload,
97
+ )
98
+ )
99
+ logger.info(
100
+ f"Broadcast feature {feature_id} {event_type} to {clients_notified} clients"
101
+ )
102
+
103
+ except Exception as e:
104
+ # Never fail the save due to broadcast error
105
+ logger.warning(f"Failed to broadcast feature save: {e}")
106
+
107
+
108
+ def broadcast_status_change(
109
+ feature_id: str,
110
+ old_status: str,
111
+ new_status: str,
112
+ agent_id: str,
113
+ session_id: str,
114
+ ) -> None:
115
+ """
116
+ Broadcast feature status change to all sessions.
117
+
118
+ Synchronous wrapper for async broadcast operation.
119
+
120
+ Args:
121
+ feature_id: Feature being updated
122
+ old_status: Previous status
123
+ new_status: New status
124
+ agent_id: Agent making change
125
+ session_id: Source session
126
+ """
127
+ try:
128
+ from pathlib import Path
129
+
130
+ from htmlgraph.api.broadcast import CrossSessionBroadcaster
131
+ from htmlgraph.api.websocket import WebSocketManager
132
+
133
+ db_path = str(Path.home() / ".htmlgraph" / "htmlgraph.db")
134
+ websocket_manager = WebSocketManager(db_path)
135
+ broadcaster = CrossSessionBroadcaster(websocket_manager, db_path)
136
+
137
+ loop = get_or_create_event_loop()
138
+
139
+ if loop.is_running():
140
+ asyncio.create_task(
141
+ broadcaster.broadcast_status_change(
142
+ feature_id=feature_id,
143
+ old_status=old_status,
144
+ new_status=new_status,
145
+ agent_id=agent_id,
146
+ session_id=session_id,
147
+ )
148
+ )
149
+ logger.debug(f"Scheduled status change broadcast for {feature_id}")
150
+ else:
151
+ clients_notified = loop.run_until_complete(
152
+ broadcaster.broadcast_status_change(
153
+ feature_id=feature_id,
154
+ old_status=old_status,
155
+ new_status=new_status,
156
+ agent_id=agent_id,
157
+ session_id=session_id,
158
+ )
159
+ )
160
+ logger.info(
161
+ f"Broadcast status change for {feature_id}: {old_status} → {new_status} "
162
+ f"to {clients_notified} clients"
163
+ )
164
+
165
+ except Exception as e:
166
+ logger.warning(f"Failed to broadcast status change: {e}")
167
+
168
+
169
+ def broadcast_link_added(
170
+ feature_id: str,
171
+ linked_feature_id: str,
172
+ link_type: str,
173
+ agent_id: str,
174
+ session_id: str,
175
+ ) -> None:
176
+ """
177
+ Broadcast feature link addition to all sessions.
178
+
179
+ Synchronous wrapper for async broadcast operation.
180
+
181
+ Args:
182
+ feature_id: Source feature
183
+ linked_feature_id: Target feature
184
+ link_type: Type of relationship
185
+ agent_id: Agent making change
186
+ session_id: Source session
187
+ """
188
+ try:
189
+ from pathlib import Path
190
+
191
+ from htmlgraph.api.broadcast import CrossSessionBroadcaster
192
+ from htmlgraph.api.websocket import WebSocketManager
193
+
194
+ db_path = str(Path.home() / ".htmlgraph" / "htmlgraph.db")
195
+ websocket_manager = WebSocketManager(db_path)
196
+ broadcaster = CrossSessionBroadcaster(websocket_manager, db_path)
197
+
198
+ loop = get_or_create_event_loop()
199
+
200
+ if loop.is_running():
201
+ asyncio.create_task(
202
+ broadcaster.broadcast_link_added(
203
+ feature_id=feature_id,
204
+ linked_feature_id=linked_feature_id,
205
+ link_type=link_type,
206
+ agent_id=agent_id,
207
+ session_id=session_id,
208
+ )
209
+ )
210
+ logger.debug(f"Scheduled link broadcast for {feature_id}")
211
+ else:
212
+ clients_notified = loop.run_until_complete(
213
+ broadcaster.broadcast_link_added(
214
+ feature_id=feature_id,
215
+ linked_feature_id=linked_feature_id,
216
+ link_type=link_type,
217
+ agent_id=agent_id,
218
+ session_id=session_id,
219
+ )
220
+ )
221
+ logger.info(
222
+ f"Broadcast link addition for {feature_id} → {linked_feature_id} "
223
+ f"({link_type}) to {clients_notified} clients"
224
+ )
225
+
226
+ except Exception as e:
227
+ logger.warning(f"Failed to broadcast link addition: {e}")
@@ -0,0 +1,207 @@
1
+ """
2
+ CLI commands for Git-based sync management.
3
+
4
+ Provides commands to start/stop background sync, trigger manual operations,
5
+ and check sync status.
6
+ """
7
+
8
+ import asyncio
9
+ from pathlib import Path
10
+
11
+ import click
12
+
13
+ from htmlgraph.sync import GitSyncManager, SyncConfig, SyncStrategy
14
+
15
+
16
+ @click.group()
17
+ def sync() -> None:
18
+ """Git-based multi-device sync commands."""
19
+ pass
20
+
21
+
22
+ @sync.command()
23
+ @click.option(
24
+ "--push-interval",
25
+ type=int,
26
+ default=300,
27
+ help="Push interval in seconds (default: 300 = 5 min)",
28
+ )
29
+ @click.option(
30
+ "--pull-interval",
31
+ type=int,
32
+ default=60,
33
+ help="Pull interval in seconds (default: 60 = 1 min)",
34
+ )
35
+ @click.option(
36
+ "--strategy",
37
+ type=click.Choice(["auto_merge", "abort_on_conflict", "ours", "theirs"]),
38
+ default="auto_merge",
39
+ help="Conflict resolution strategy",
40
+ )
41
+ @click.option(
42
+ "--repo-root",
43
+ type=click.Path(exists=True),
44
+ default=".",
45
+ help="Git repository root path",
46
+ )
47
+ def start(
48
+ push_interval: int, pull_interval: int, strategy: str, repo_root: str
49
+ ) -> None:
50
+ """Start background sync service."""
51
+ config = SyncConfig(
52
+ push_interval_seconds=push_interval,
53
+ pull_interval_seconds=pull_interval,
54
+ conflict_strategy=SyncStrategy(strategy),
55
+ )
56
+
57
+ manager = GitSyncManager(repo_root, config)
58
+
59
+ click.echo("Starting background sync service...")
60
+ click.echo(f" Push interval: {push_interval}s")
61
+ click.echo(f" Pull interval: {pull_interval}s")
62
+ click.echo(f" Conflict strategy: {strategy}")
63
+ click.echo(f" Repository: {Path(repo_root).absolute()}")
64
+
65
+ try:
66
+ asyncio.run(manager.start_background_sync())
67
+ except KeyboardInterrupt:
68
+ click.echo("\nStopping background sync...")
69
+ asyncio.run(manager.stop_background_sync())
70
+
71
+
72
+ @sync.command()
73
+ @click.option(
74
+ "--repo-root",
75
+ type=click.Path(exists=True),
76
+ default=".",
77
+ help="Git repository root path",
78
+ )
79
+ def push(repo_root: str) -> None:
80
+ """Manually push changes to remote."""
81
+ manager = GitSyncManager(repo_root)
82
+
83
+ click.echo("Pushing changes to remote...")
84
+
85
+ async def do_push() -> None:
86
+ result = await manager.push(force=True)
87
+ click.echo(f"Status: {result.status.value}")
88
+ click.echo(f"Files changed: {result.files_changed}")
89
+ click.echo(f"Message: {result.message}")
90
+ if result.conflicts:
91
+ click.echo(f"Conflicts: {', '.join(result.conflicts)}")
92
+
93
+ asyncio.run(do_push())
94
+
95
+
96
+ @sync.command()
97
+ @click.option(
98
+ "--repo-root",
99
+ type=click.Path(exists=True),
100
+ default=".",
101
+ help="Git repository root path",
102
+ )
103
+ def pull(repo_root: str) -> None:
104
+ """Manually pull changes from remote."""
105
+ manager = GitSyncManager(repo_root)
106
+
107
+ click.echo("Pulling changes from remote...")
108
+
109
+ async def do_pull() -> None:
110
+ result = await manager.pull(force=True)
111
+ click.echo(f"Status: {result.status.value}")
112
+ click.echo(f"Message: {result.message}")
113
+ if result.conflicts:
114
+ click.echo(f"Conflicts: {', '.join(result.conflicts)}")
115
+
116
+ asyncio.run(do_pull())
117
+
118
+
119
+ @sync.command()
120
+ @click.option(
121
+ "--repo-root",
122
+ type=click.Path(exists=True),
123
+ default=".",
124
+ help="Git repository root path",
125
+ )
126
+ @click.option(
127
+ "--limit",
128
+ type=int,
129
+ default=10,
130
+ help="Number of recent operations to show",
131
+ )
132
+ def status(repo_root: str, limit: int) -> None:
133
+ """Show sync status and recent history."""
134
+ manager = GitSyncManager(repo_root)
135
+
136
+ status_data = manager.get_status()
137
+ history = manager.get_sync_history(limit)
138
+
139
+ click.echo("Sync Status:")
140
+ click.echo(f" Current status: {status_data['status']}")
141
+ click.echo(f" Last push: {status_data['last_push'] or 'Never'}")
142
+ click.echo(f" Last pull: {status_data['last_pull'] or 'Never'}")
143
+ click.echo("\nConfiguration:")
144
+ click.echo(f" Remote: {status_data['config']['remote']}")
145
+ click.echo(f" Branch: {status_data['config']['branch']}")
146
+ click.echo(f" Push interval: {status_data['config']['push_interval']}s")
147
+ click.echo(f" Pull interval: {status_data['config']['pull_interval']}s")
148
+ click.echo(f" Conflict strategy: {status_data['config']['conflict_strategy']}")
149
+
150
+ if history:
151
+ click.echo(f"\nRecent Operations (last {len(history)}):")
152
+ for entry in history:
153
+ click.echo(
154
+ f" [{entry['operation']}] {entry['status']} - {entry['message']}"
155
+ )
156
+
157
+
158
+ @sync.command()
159
+ @click.option(
160
+ "--repo-root",
161
+ type=click.Path(exists=True),
162
+ default=".",
163
+ help="Git repository root path",
164
+ )
165
+ @click.option(
166
+ "--push-interval",
167
+ type=int,
168
+ help="New push interval in seconds",
169
+ )
170
+ @click.option(
171
+ "--pull-interval",
172
+ type=int,
173
+ help="New pull interval in seconds",
174
+ )
175
+ @click.option(
176
+ "--strategy",
177
+ type=click.Choice(["auto_merge", "abort_on_conflict", "ours", "theirs"]),
178
+ help="New conflict resolution strategy",
179
+ )
180
+ def configure(
181
+ repo_root: str,
182
+ push_interval: int | None,
183
+ pull_interval: int | None,
184
+ strategy: str | None,
185
+ ) -> None:
186
+ """Update sync configuration."""
187
+ manager = GitSyncManager(repo_root)
188
+
189
+ if push_interval:
190
+ manager.config.push_interval_seconds = push_interval
191
+ click.echo(f"Push interval updated to {push_interval}s")
192
+
193
+ if pull_interval:
194
+ manager.config.pull_interval_seconds = pull_interval
195
+ click.echo(f"Pull interval updated to {pull_interval}s")
196
+
197
+ if strategy:
198
+ manager.config.conflict_strategy = SyncStrategy(strategy)
199
+ click.echo(f"Conflict strategy updated to {strategy}")
200
+
201
+ if not any([push_interval, pull_interval, strategy]):
202
+ click.echo("No configuration changes specified")
203
+ click.echo("Use --push-interval, --pull-interval, or --strategy")
204
+
205
+
206
+ if __name__ == "__main__":
207
+ sync()