stravinsky 0.2.67__py3-none-any.whl → 0.4.66__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.

Potentially problematic release.


This version of stravinsky might be problematic. Click here for more details.

Files changed (190) hide show
  1. mcp_bridge/__init__.py +1 -1
  2. mcp_bridge/auth/__init__.py +16 -6
  3. mcp_bridge/auth/cli.py +202 -11
  4. mcp_bridge/auth/oauth.py +1 -2
  5. mcp_bridge/auth/openai_oauth.py +4 -7
  6. mcp_bridge/auth/token_store.py +112 -11
  7. mcp_bridge/cli/__init__.py +1 -1
  8. mcp_bridge/cli/install_hooks.py +503 -107
  9. mcp_bridge/cli/session_report.py +0 -3
  10. mcp_bridge/config/MANIFEST_SCHEMA.md +305 -0
  11. mcp_bridge/config/README.md +276 -0
  12. mcp_bridge/config/__init__.py +2 -2
  13. mcp_bridge/config/hook_config.py +247 -0
  14. mcp_bridge/config/hooks_manifest.json +138 -0
  15. mcp_bridge/config/rate_limits.py +317 -0
  16. mcp_bridge/config/skills_manifest.json +128 -0
  17. mcp_bridge/hooks/HOOKS_SETTINGS.json +17 -4
  18. mcp_bridge/hooks/__init__.py +19 -4
  19. mcp_bridge/hooks/agent_reminder.py +4 -4
  20. mcp_bridge/hooks/auto_slash_command.py +5 -5
  21. mcp_bridge/hooks/budget_optimizer.py +2 -2
  22. mcp_bridge/hooks/claude_limits_hook.py +114 -0
  23. mcp_bridge/hooks/comment_checker.py +3 -4
  24. mcp_bridge/hooks/compaction.py +2 -2
  25. mcp_bridge/hooks/context.py +2 -1
  26. mcp_bridge/hooks/context_monitor.py +2 -2
  27. mcp_bridge/hooks/delegation_policy.py +85 -0
  28. mcp_bridge/hooks/directory_context.py +3 -3
  29. mcp_bridge/hooks/edit_recovery.py +3 -2
  30. mcp_bridge/hooks/edit_recovery_policy.py +49 -0
  31. mcp_bridge/hooks/empty_message_sanitizer.py +2 -2
  32. mcp_bridge/hooks/events.py +160 -0
  33. mcp_bridge/hooks/git_noninteractive.py +4 -4
  34. mcp_bridge/hooks/keyword_detector.py +8 -10
  35. mcp_bridge/hooks/manager.py +43 -22
  36. mcp_bridge/hooks/notification_hook.py +13 -6
  37. mcp_bridge/hooks/parallel_enforcement_policy.py +67 -0
  38. mcp_bridge/hooks/parallel_enforcer.py +5 -5
  39. mcp_bridge/hooks/parallel_execution.py +22 -10
  40. mcp_bridge/hooks/post_tool/parallel_validation.py +103 -0
  41. mcp_bridge/hooks/pre_compact.py +8 -9
  42. mcp_bridge/hooks/pre_tool/agent_spawn_validator.py +115 -0
  43. mcp_bridge/hooks/preemptive_compaction.py +2 -3
  44. mcp_bridge/hooks/routing_notifications.py +80 -0
  45. mcp_bridge/hooks/rules_injector.py +11 -19
  46. mcp_bridge/hooks/session_idle.py +4 -4
  47. mcp_bridge/hooks/session_notifier.py +4 -4
  48. mcp_bridge/hooks/session_recovery.py +4 -5
  49. mcp_bridge/hooks/stravinsky_mode.py +1 -1
  50. mcp_bridge/hooks/subagent_stop.py +1 -3
  51. mcp_bridge/hooks/task_validator.py +2 -2
  52. mcp_bridge/hooks/tmux_manager.py +7 -8
  53. mcp_bridge/hooks/todo_delegation.py +4 -1
  54. mcp_bridge/hooks/todo_enforcer.py +180 -10
  55. mcp_bridge/hooks/tool_messaging.py +113 -10
  56. mcp_bridge/hooks/truncation_policy.py +37 -0
  57. mcp_bridge/hooks/truncator.py +1 -2
  58. mcp_bridge/metrics/cost_tracker.py +115 -0
  59. mcp_bridge/native_search.py +93 -0
  60. mcp_bridge/native_watcher.py +118 -0
  61. mcp_bridge/notifications.py +150 -0
  62. mcp_bridge/orchestrator/enums.py +11 -0
  63. mcp_bridge/orchestrator/router.py +165 -0
  64. mcp_bridge/orchestrator/state.py +32 -0
  65. mcp_bridge/orchestrator/visualization.py +14 -0
  66. mcp_bridge/orchestrator/wisdom.py +34 -0
  67. mcp_bridge/prompts/__init__.py +1 -8
  68. mcp_bridge/prompts/dewey.py +1 -1
  69. mcp_bridge/prompts/planner.py +2 -4
  70. mcp_bridge/prompts/stravinsky.py +53 -31
  71. mcp_bridge/proxy/__init__.py +0 -0
  72. mcp_bridge/proxy/client.py +70 -0
  73. mcp_bridge/proxy/model_server.py +157 -0
  74. mcp_bridge/routing/__init__.py +43 -0
  75. mcp_bridge/routing/config.py +250 -0
  76. mcp_bridge/routing/model_tiers.py +135 -0
  77. mcp_bridge/routing/provider_state.py +261 -0
  78. mcp_bridge/routing/task_classifier.py +190 -0
  79. mcp_bridge/server.py +542 -59
  80. mcp_bridge/server_tools.py +738 -6
  81. mcp_bridge/tools/__init__.py +40 -25
  82. mcp_bridge/tools/agent_manager.py +616 -697
  83. mcp_bridge/tools/background_tasks.py +13 -17
  84. mcp_bridge/tools/code_search.py +70 -53
  85. mcp_bridge/tools/continuous_loop.py +0 -1
  86. mcp_bridge/tools/dashboard.py +19 -0
  87. mcp_bridge/tools/find_code.py +296 -0
  88. mcp_bridge/tools/init.py +1 -0
  89. mcp_bridge/tools/list_directory.py +42 -0
  90. mcp_bridge/tools/lsp/__init__.py +12 -5
  91. mcp_bridge/tools/lsp/manager.py +471 -0
  92. mcp_bridge/tools/lsp/tools.py +723 -207
  93. mcp_bridge/tools/model_invoke.py +1195 -273
  94. mcp_bridge/tools/mux_client.py +75 -0
  95. mcp_bridge/tools/project_context.py +1 -2
  96. mcp_bridge/tools/query_classifier.py +406 -0
  97. mcp_bridge/tools/read_file.py +84 -0
  98. mcp_bridge/tools/replace.py +45 -0
  99. mcp_bridge/tools/run_shell_command.py +38 -0
  100. mcp_bridge/tools/search_enhancements.py +347 -0
  101. mcp_bridge/tools/semantic_search.py +3627 -0
  102. mcp_bridge/tools/session_manager.py +0 -2
  103. mcp_bridge/tools/skill_loader.py +0 -1
  104. mcp_bridge/tools/task_runner.py +5 -7
  105. mcp_bridge/tools/templates.py +3 -3
  106. mcp_bridge/tools/tool_search.py +331 -0
  107. mcp_bridge/tools/write_file.py +29 -0
  108. mcp_bridge/update_manager.py +585 -0
  109. mcp_bridge/update_manager_pypi.py +297 -0
  110. mcp_bridge/utils/cache.py +82 -0
  111. mcp_bridge/utils/process.py +71 -0
  112. mcp_bridge/utils/session_state.py +51 -0
  113. mcp_bridge/utils/truncation.py +76 -0
  114. stravinsky-0.4.66.dist-info/METADATA +517 -0
  115. stravinsky-0.4.66.dist-info/RECORD +198 -0
  116. {stravinsky-0.2.67.dist-info → stravinsky-0.4.66.dist-info}/entry_points.txt +1 -0
  117. stravinsky_claude_assets/HOOKS_INTEGRATION.md +316 -0
  118. stravinsky_claude_assets/agents/HOOKS.md +437 -0
  119. stravinsky_claude_assets/agents/code-reviewer.md +210 -0
  120. stravinsky_claude_assets/agents/comment_checker.md +580 -0
  121. stravinsky_claude_assets/agents/debugger.md +254 -0
  122. stravinsky_claude_assets/agents/delphi.md +495 -0
  123. stravinsky_claude_assets/agents/dewey.md +248 -0
  124. stravinsky_claude_assets/agents/explore.md +1198 -0
  125. stravinsky_claude_assets/agents/frontend.md +472 -0
  126. stravinsky_claude_assets/agents/implementation-lead.md +164 -0
  127. stravinsky_claude_assets/agents/momus.md +464 -0
  128. stravinsky_claude_assets/agents/research-lead.md +141 -0
  129. stravinsky_claude_assets/agents/stravinsky.md +730 -0
  130. stravinsky_claude_assets/commands/delphi.md +9 -0
  131. stravinsky_claude_assets/commands/dewey.md +54 -0
  132. stravinsky_claude_assets/commands/git-master.md +112 -0
  133. stravinsky_claude_assets/commands/index.md +49 -0
  134. stravinsky_claude_assets/commands/publish.md +86 -0
  135. stravinsky_claude_assets/commands/review.md +73 -0
  136. stravinsky_claude_assets/commands/str/agent_cancel.md +70 -0
  137. stravinsky_claude_assets/commands/str/agent_list.md +56 -0
  138. stravinsky_claude_assets/commands/str/agent_output.md +92 -0
  139. stravinsky_claude_assets/commands/str/agent_progress.md +74 -0
  140. stravinsky_claude_assets/commands/str/agent_retry.md +94 -0
  141. stravinsky_claude_assets/commands/str/cancel.md +51 -0
  142. stravinsky_claude_assets/commands/str/clean.md +97 -0
  143. stravinsky_claude_assets/commands/str/continue.md +38 -0
  144. stravinsky_claude_assets/commands/str/index.md +199 -0
  145. stravinsky_claude_assets/commands/str/list_watchers.md +96 -0
  146. stravinsky_claude_assets/commands/str/search.md +205 -0
  147. stravinsky_claude_assets/commands/str/start_filewatch.md +136 -0
  148. stravinsky_claude_assets/commands/str/stats.md +71 -0
  149. stravinsky_claude_assets/commands/str/stop_filewatch.md +89 -0
  150. stravinsky_claude_assets/commands/str/unwatch.md +42 -0
  151. stravinsky_claude_assets/commands/str/watch.md +45 -0
  152. stravinsky_claude_assets/commands/strav.md +53 -0
  153. stravinsky_claude_assets/commands/stravinsky.md +292 -0
  154. stravinsky_claude_assets/commands/verify.md +60 -0
  155. stravinsky_claude_assets/commands/version.md +5 -0
  156. stravinsky_claude_assets/hooks/README.md +248 -0
  157. stravinsky_claude_assets/hooks/comment_checker.py +193 -0
  158. stravinsky_claude_assets/hooks/context.py +38 -0
  159. stravinsky_claude_assets/hooks/context_monitor.py +153 -0
  160. stravinsky_claude_assets/hooks/dependency_tracker.py +73 -0
  161. stravinsky_claude_assets/hooks/edit_recovery.py +46 -0
  162. stravinsky_claude_assets/hooks/execution_state_tracker.py +68 -0
  163. stravinsky_claude_assets/hooks/notification_hook.py +103 -0
  164. stravinsky_claude_assets/hooks/notification_hook_v2.py +96 -0
  165. stravinsky_claude_assets/hooks/parallel_execution.py +241 -0
  166. stravinsky_claude_assets/hooks/parallel_reinforcement.py +106 -0
  167. stravinsky_claude_assets/hooks/parallel_reinforcement_v2.py +112 -0
  168. stravinsky_claude_assets/hooks/pre_compact.py +123 -0
  169. stravinsky_claude_assets/hooks/ralph_loop.py +173 -0
  170. stravinsky_claude_assets/hooks/session_recovery.py +263 -0
  171. stravinsky_claude_assets/hooks/stop_hook.py +89 -0
  172. stravinsky_claude_assets/hooks/stravinsky_metrics.py +164 -0
  173. stravinsky_claude_assets/hooks/stravinsky_mode.py +146 -0
  174. stravinsky_claude_assets/hooks/subagent_stop.py +98 -0
  175. stravinsky_claude_assets/hooks/todo_continuation.py +111 -0
  176. stravinsky_claude_assets/hooks/todo_delegation.py +96 -0
  177. stravinsky_claude_assets/hooks/tool_messaging.py +281 -0
  178. stravinsky_claude_assets/hooks/truncator.py +23 -0
  179. stravinsky_claude_assets/rules/deployment_safety.md +51 -0
  180. stravinsky_claude_assets/rules/integration_wiring.md +89 -0
  181. stravinsky_claude_assets/rules/pypi_deployment.md +220 -0
  182. stravinsky_claude_assets/rules/stravinsky_orchestrator.md +32 -0
  183. stravinsky_claude_assets/settings.json +152 -0
  184. stravinsky_claude_assets/skills/chrome-devtools/SKILL.md +81 -0
  185. stravinsky_claude_assets/skills/sqlite/SKILL.md +77 -0
  186. stravinsky_claude_assets/skills/supabase/SKILL.md +74 -0
  187. stravinsky_claude_assets/task_dependencies.json +34 -0
  188. stravinsky-0.2.67.dist-info/METADATA +0 -284
  189. stravinsky-0.2.67.dist-info/RECORD +0 -76
  190. {stravinsky-0.2.67.dist-info → stravinsky-0.4.66.dist-info}/WHEEL +0 -0
@@ -0,0 +1,585 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Update Manager for Stravinsky Hooks and Skills
4
+
5
+ Safely merges hooks and skills during Stravinsky updates with:
6
+ - Version tracking via manifest files
7
+ - 3-way merge algorithm (base, user, new)
8
+ - User customization preservation
9
+ - Conflict detection and reporting
10
+ - Automatic backups before updates
11
+ - Rollback capability
12
+ - Dry-run mode for testing
13
+ - Comprehensive logging
14
+ """
15
+
16
+ import json
17
+ import logging
18
+ import shutil
19
+ import sys
20
+ from dataclasses import asdict, dataclass
21
+ from datetime import datetime
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+
26
+ @dataclass
27
+ class MergeConflict:
28
+ """Represents a merge conflict for a file."""
29
+ file_path: str
30
+ base_version: str | None
31
+ user_version: str | None
32
+ new_version: str | None
33
+ conflict_type: str
34
+
35
+
36
+ @dataclass
37
+ class UpdateManifest:
38
+ """Manifest tracking file versions and update status."""
39
+ version: str
40
+ timestamp: str
41
+ files: dict[str, str]
42
+
43
+ def to_dict(self) -> dict[str, Any]:
44
+ return asdict(self)
45
+
46
+ @staticmethod
47
+ def from_dict(data: dict[str, Any]) -> 'UpdateManifest':
48
+ return UpdateManifest(
49
+ version=data.get('version', ''),
50
+ timestamp=data.get('timestamp', ''),
51
+ files=data.get('files', {})
52
+ )
53
+
54
+
55
+ class UpdateManager:
56
+ """Manages safe updates of hooks and skills with conflict detection and rollback."""
57
+
58
+ def __init__(self, dry_run: bool = False, verbose: bool = False):
59
+ """Initialize update manager."""
60
+ self.dry_run = dry_run
61
+ self.verbose = verbose
62
+ self.home = Path.home()
63
+ self.global_claude_dir = self.home / ".claude"
64
+ self.backup_dir = self.global_claude_dir / ".backups"
65
+ self.manifest_dir = self.global_claude_dir / ".manifests"
66
+
67
+ self.logger = self._setup_logging()
68
+
69
+ if not self.dry_run:
70
+ self.backup_dir.mkdir(parents=True, exist_ok=True)
71
+ self.manifest_dir.mkdir(parents=True, exist_ok=True)
72
+
73
+ def _setup_logging(self) -> logging.Logger:
74
+ """Setup logging with file and console output."""
75
+ logger = logging.getLogger("stravinsky.update_manager")
76
+ logger.setLevel(logging.DEBUG if self.verbose else logging.INFO)
77
+ logger.handlers.clear()
78
+
79
+ log_dir = self.global_claude_dir / ".logs"
80
+ if not self.dry_run:
81
+ log_dir.mkdir(parents=True, exist_ok=True)
82
+ fh = logging.FileHandler(log_dir / "update_manager.log")
83
+ fh.setLevel(logging.DEBUG)
84
+ formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
85
+ fh.setFormatter(formatter)
86
+ logger.addHandler(fh)
87
+
88
+ ch = logging.StreamHandler()
89
+ ch.setLevel(logging.WARNING if not self.verbose else logging.DEBUG)
90
+ formatter = logging.Formatter('%(levelname)s: %(message)s')
91
+ ch.setFormatter(formatter)
92
+ logger.addHandler(ch)
93
+
94
+ return logger
95
+
96
+ def _hash_file(self, path: Path) -> str:
97
+ """Generate hash of file content."""
98
+ import hashlib
99
+ try:
100
+ content = path.read_bytes()
101
+ return hashlib.sha256(content).hexdigest()[:16]
102
+ except Exception:
103
+ return "unknown"
104
+
105
+ def _load_manifest(self, manifest_type: str) -> UpdateManifest | None:
106
+ """Load manifest file (base, user, new)."""
107
+ manifest_path = self.manifest_dir / f"{manifest_type}_manifest.json"
108
+
109
+ if not manifest_path.exists():
110
+ return None
111
+
112
+ try:
113
+ data = json.loads(manifest_path.read_text())
114
+ return UpdateManifest.from_dict(data)
115
+ except Exception as e:
116
+ self.logger.error(f"Failed to load manifest: {e}")
117
+ return None
118
+
119
+ def _save_manifest(self, manifest: UpdateManifest, manifest_type: str) -> bool:
120
+ """Save manifest file."""
121
+ if self.dry_run:
122
+ self.logger.debug(f"[DRY-RUN] Would save {manifest_type} manifest")
123
+ return True
124
+
125
+ manifest_path = self.manifest_dir / f"{manifest_type}_manifest.json"
126
+
127
+ try:
128
+ manifest_path.write_text(json.dumps(manifest.to_dict(), indent=2))
129
+ self.logger.info(f"Saved {manifest_type} manifest")
130
+ return True
131
+ except Exception as e:
132
+ self.logger.error(f"Failed to save manifest: {e}")
133
+ return False
134
+
135
+ def _create_backup(self, source_dir: Path, backup_name: str) -> Path | None:
136
+ """Create timestamped backup of directory."""
137
+ if self.dry_run:
138
+ return None
139
+
140
+ if not source_dir.exists():
141
+ return None
142
+
143
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
144
+ backup_path = self.backup_dir / f"{backup_name}_{timestamp}"
145
+
146
+ try:
147
+ shutil.copytree(source_dir, backup_path)
148
+ self.logger.info(f"Created backup: {backup_path}")
149
+ return backup_path
150
+ except Exception as e:
151
+ self.logger.error(f"Failed to create backup: {e}")
152
+ return None
153
+
154
+ def _read_file_safely(self, path: Path) -> str | None:
155
+ """Read file with error handling."""
156
+ try:
157
+ if not path.exists():
158
+ return None
159
+ return path.read_text(encoding='utf-8')
160
+ except Exception as e:
161
+ self.logger.error(f"Failed to read {path}: {e}")
162
+ return None
163
+
164
+ def _write_file_safely(self, path: Path, content: str) -> bool:
165
+ """Write file with error handling."""
166
+ if self.dry_run:
167
+ self.logger.debug(f"[DRY-RUN] Would write {len(content)} bytes to {path}")
168
+ return True
169
+
170
+ try:
171
+ path.parent.mkdir(parents=True, exist_ok=True)
172
+ path.write_text(content, encoding='utf-8')
173
+ path.chmod(0o755)
174
+ self.logger.debug(f"Wrote {len(content)} bytes to {path}")
175
+ return True
176
+ except Exception as e:
177
+ self.logger.error(f"Failed to write {path}: {e}")
178
+ return False
179
+
180
+ def _detect_conflicts(
181
+ self,
182
+ base: str | None,
183
+ user: str | None,
184
+ new: str | None,
185
+ file_path: str
186
+ ) -> MergeConflict | None:
187
+ """Detect merge conflicts using 3-way merge logic."""
188
+ if new == base:
189
+ return None
190
+
191
+ if user == base or user is None:
192
+ return None
193
+
194
+ if user == new:
195
+ return None
196
+
197
+ conflict_type = "different_modifications"
198
+ if base is None and user is not None and new is not None:
199
+ conflict_type = "added_both_ways"
200
+ elif base is not None and user is None and new is not None:
201
+ conflict_type = "deleted_vs_new"
202
+
203
+ return MergeConflict(
204
+ file_path=file_path,
205
+ base_version=base[:50] if base else None,
206
+ user_version=user[:50] if user else None,
207
+ new_version=new[:50] if new else None,
208
+ conflict_type=conflict_type
209
+ )
210
+
211
+ def _merge_3way(
212
+ self,
213
+ base: str | None,
214
+ user: str | None,
215
+ new: str | None,
216
+ file_path: str
217
+ ) -> tuple[str, bool]:
218
+ """Perform 3-way merge on file content."""
219
+ if base is None:
220
+ if user is None:
221
+ return new or "", False
222
+ elif new is None or user == new:
223
+ return user, False
224
+ else:
225
+ return self._format_conflict_markers(user, new), True
226
+
227
+ if user is None:
228
+ if new is None:
229
+ return "", False
230
+ else:
231
+ return self._format_conflict_markers(None, new), True
232
+
233
+ if new is None:
234
+ return self._format_conflict_markers(user, None), True
235
+
236
+ if base == new:
237
+ return user, False
238
+
239
+ if base == user:
240
+ return new, False
241
+
242
+ if user != new:
243
+ merged, has_conflict = self._line_based_merge(base, user, new)
244
+ return merged, has_conflict
245
+
246
+ return user, False
247
+
248
+ def _line_based_merge(self, base: str, user: str, new: str) -> tuple[str, bool]:
249
+ """Perform line-based merge for text conflicts."""
250
+ base_lines = base.splitlines(keepends=True)
251
+ user_lines = user.splitlines(keepends=True)
252
+ new_lines = new.splitlines(keepends=True)
253
+
254
+ merged = []
255
+ has_conflict = False
256
+
257
+ if len(base_lines) == len(user_lines) == len(new_lines):
258
+ for i, (b, u, n) in enumerate(zip(base_lines, user_lines, new_lines)):
259
+ if u == b == n:
260
+ merged.append(u)
261
+ elif u == b and n != b:
262
+ merged.append(n)
263
+ elif n == b and u != b or u == n:
264
+ merged.append(u)
265
+ else:
266
+ merged.append(f"<<<<<<< {u}======= {n}>>>>>>> ")
267
+ has_conflict = True
268
+ else:
269
+ has_conflict = True
270
+ merged.append("<<<<<<< USER VERSION\n")
271
+ merged.extend(user_lines)
272
+ merged.append("=======\n")
273
+ merged.extend(new_lines)
274
+ merged.append(">>>>>>> NEW VERSION\n")
275
+
276
+ return "".join(merged), has_conflict
277
+
278
+ def _format_conflict_markers(self, user: str | None, new: str | None) -> str:
279
+ """Format conflict markers for display."""
280
+ lines = ["<<<<<<< USER VERSION\n"]
281
+ if user:
282
+ lines.append(user)
283
+ if not user.endswith('\n'):
284
+ lines.append('\n')
285
+ lines.append("=======\n")
286
+ if new:
287
+ lines.append(new)
288
+ if not new.endswith('\n'):
289
+ lines.append('\n')
290
+ lines.append(">>>>>>> NEW VERSION\n")
291
+ return "".join(lines)
292
+
293
+ def _preserve_statusline(self, settings_file: Path) -> dict[str, Any] | None:
294
+ """Read and preserve statusline from settings.json."""
295
+ try:
296
+ if not settings_file.exists():
297
+ return None
298
+ settings = json.loads(settings_file.read_text())
299
+ statusline = settings.get("statusLine")
300
+ if statusline:
301
+ self.logger.debug(f"Preserved statusline: {statusline}")
302
+ return statusline
303
+ except Exception as e:
304
+ self.logger.error(f"Failed to read statusline: {e}")
305
+ return None
306
+
307
+ def _merge_settings_json(
308
+ self,
309
+ base: dict[str, Any] | None,
310
+ user: dict[str, Any] | None,
311
+ new: dict[str, Any] | None
312
+ ) -> tuple[dict[str, Any], list[MergeConflict]]:
313
+ """Merge settings.json with special handling for hooks and statusline."""
314
+ conflicts = []
315
+
316
+ if base is None:
317
+ base = {}
318
+ if user is None:
319
+ user = {}
320
+ if new is None:
321
+ new = {}
322
+
323
+ merged = {}
324
+
325
+ if "statusLine" in user:
326
+ merged["statusLine"] = user["statusLine"]
327
+ self.logger.debug("Preserved user statusLine")
328
+ elif "statusLine" in new:
329
+ merged["statusLine"] = new["statusLine"]
330
+
331
+ user_hooks = user.get("hooks", {})
332
+ new_hooks = new.get("hooks", {})
333
+ base_hooks = base.get("hooks", {})
334
+
335
+ merged_hooks = {}
336
+
337
+ for hook_type in set(list(user_hooks.keys()) + list(new_hooks.keys()) + list(base_hooks.keys())):
338
+ user_type_hooks = user_hooks.get(hook_type, [])
339
+ new_type_hooks = new_hooks.get(hook_type, [])
340
+ base_type_hooks = base_hooks.get(hook_type, [])
341
+
342
+ merged_type_hooks = user_type_hooks.copy()
343
+
344
+ for new_hook in new_type_hooks:
345
+ if new_hook not in base_type_hooks and new_hook not in merged_type_hooks:
346
+ merged_type_hooks.append(new_hook)
347
+ self.logger.debug(f"Added new {hook_type} hook")
348
+
349
+ if merged_type_hooks:
350
+ merged_hooks[hook_type] = merged_type_hooks
351
+
352
+ if merged_hooks:
353
+ merged["hooks"] = merged_hooks
354
+
355
+ for key in set(list(user.keys()) + list(new.keys()) + list(base.keys())):
356
+ if key in ("hooks", "statusLine"):
357
+ continue
358
+
359
+ if key in user:
360
+ merged[key] = user[key]
361
+ elif key in new:
362
+ merged[key] = new[key]
363
+
364
+ return merged, conflicts
365
+
366
+ def update_hooks(
367
+ self,
368
+ new_hooks: dict[str, str],
369
+ stravinsky_version: str
370
+ ) -> tuple[bool, list[MergeConflict]]:
371
+ """Update hooks with 3-way merge and conflict detection."""
372
+ self.logger.info(f"Starting hooks update to version {stravinsky_version}")
373
+
374
+ hooks_dir = self.global_claude_dir / "hooks"
375
+ conflicts = []
376
+
377
+ backup_path = self._create_backup(hooks_dir, "hooks")
378
+
379
+ base_manifest = self._load_manifest("base")
380
+
381
+ updated_files = {}
382
+
383
+ for filename, new_content in new_hooks.items():
384
+ hook_path = hooks_dir / filename
385
+
386
+ base_content = None
387
+ user_content = self._read_file_safely(hook_path)
388
+
389
+ if base_manifest:
390
+ base_file_hash = base_manifest.files.get(filename)
391
+ if base_file_hash and backup_path:
392
+ base_path = backup_path / filename
393
+ base_content = self._read_file_safely(base_path)
394
+
395
+ conflict = self._detect_conflicts(base_content, user_content, new_content, filename)
396
+ if conflict:
397
+ conflicts.append(conflict)
398
+ self.logger.warning(f"Conflict detected in {filename}: {conflict.conflict_type}")
399
+
400
+ merged_content, has_conflict = self._merge_3way(
401
+ base_content,
402
+ user_content,
403
+ new_content,
404
+ filename
405
+ )
406
+
407
+ if self._write_file_safely(hook_path, merged_content):
408
+ updated_files[filename] = self._hash_file(hook_path)
409
+ if has_conflict:
410
+ self.logger.warning(f"Updated {filename} with conflict markers")
411
+ else:
412
+ self.logger.info(f"Updated {filename}")
413
+ else:
414
+ self.logger.error(f"Failed to write {filename}")
415
+ return False, conflicts
416
+
417
+ new_manifest = UpdateManifest(
418
+ version=stravinsky_version,
419
+ timestamp=datetime.now().isoformat(),
420
+ files=updated_files
421
+ )
422
+
423
+ if not self._save_manifest(new_manifest, "base"):
424
+ return False, conflicts
425
+
426
+ self.logger.info(f"Hooks update completed ({len(updated_files)} files updated)")
427
+ return True, conflicts
428
+
429
+ def update_settings_json(self, new_settings: dict[str, Any]) -> tuple[bool, list[MergeConflict]]:
430
+ """Update settings.json with hook merging and statusline preservation."""
431
+ self.logger.info("Starting settings.json update")
432
+
433
+ settings_file = self.global_claude_dir / "settings.json"
434
+
435
+ self._create_backup(settings_file.parent, "settings")
436
+
437
+ user_settings = {}
438
+ if settings_file.exists():
439
+ try:
440
+ user_settings = json.loads(settings_file.read_text())
441
+ except Exception as e:
442
+ self.logger.error(f"Failed to parse settings.json: {e}")
443
+
444
+ base_settings = {}
445
+
446
+ merged_settings, conflicts = self._merge_settings_json(
447
+ base_settings or None,
448
+ user_settings or None,
449
+ new_settings or None
450
+ )
451
+
452
+ if self._write_file_safely(settings_file, json.dumps(merged_settings, indent=2)):
453
+ self.logger.info("Updated settings.json")
454
+ return True, conflicts
455
+ else:
456
+ self.logger.error("Failed to write settings.json")
457
+ return False, conflicts
458
+
459
+ def rollback(self, backup_timestamp: str) -> bool:
460
+ """Rollback to a previous backup."""
461
+ if self.dry_run:
462
+ self.logger.info(f"[DRY-RUN] Would rollback to {backup_timestamp}")
463
+ return True
464
+
465
+ self.logger.info(f"Rolling back to backup {backup_timestamp}")
466
+
467
+ backups = list(self.backup_dir.glob(f"*_{backup_timestamp}"))
468
+
469
+ if not backups:
470
+ self.logger.error(f"No backups found for timestamp {backup_timestamp}")
471
+ return False
472
+
473
+ success = True
474
+ for backup_path in backups:
475
+ try:
476
+ if "hooks" in backup_path.name:
477
+ restore_dir = self.global_claude_dir / "hooks"
478
+ elif "settings" in backup_path.name:
479
+ restore_dir = self.global_claude_dir
480
+ else:
481
+ continue
482
+
483
+ if restore_dir.exists():
484
+ shutil.rmtree(restore_dir)
485
+
486
+ shutil.copytree(backup_path, restore_dir)
487
+ self.logger.info(f"Restored from {backup_path}")
488
+ except Exception as e:
489
+ self.logger.error(f"Failed to restore from backup: {e}")
490
+ success = False
491
+
492
+ return success
493
+
494
+ def verify_integrity(self) -> tuple[bool, list[str]]:
495
+ """Verify integrity of installed hooks and settings."""
496
+ issues = []
497
+ hooks_dir = self.global_claude_dir / "hooks"
498
+ settings_file = self.global_claude_dir / "settings.json"
499
+
500
+ if not hooks_dir.exists():
501
+ issues.append("Hooks directory doesn't exist")
502
+ return False, issues
503
+
504
+ if not settings_file.exists():
505
+ issues.append("settings.json doesn't exist")
506
+ return False, issues
507
+
508
+ try:
509
+ json.loads(settings_file.read_text())
510
+ except Exception as e:
511
+ issues.append(f"settings.json is invalid: {e}")
512
+ return False, issues
513
+
514
+ if not self._load_manifest("base"):
515
+ issues.append("Base manifest missing")
516
+
517
+ for hook_file in hooks_dir.glob("*.py"):
518
+ if not (hook_file.stat().st_mode & 0o111):
519
+ issues.append(f"{hook_file.name} is not executable")
520
+
521
+ return len(issues) == 0, issues
522
+
523
+ def list_backups(self) -> list[dict[str, Any]]:
524
+ """List all available backups."""
525
+ backups = []
526
+
527
+ if not self.backup_dir.exists():
528
+ return backups
529
+
530
+ for backup_path in sorted(self.backup_dir.iterdir(), reverse=True):
531
+ if backup_path.is_dir():
532
+ stat = backup_path.stat()
533
+ size_mb = sum(f.stat().st_size for f in backup_path.rglob('*') if f.is_file()) / (1024 * 1024)
534
+ backups.append({
535
+ "name": backup_path.name,
536
+ "size_mb": size_mb,
537
+ "created": datetime.fromtimestamp(stat.st_mtime).isoformat()
538
+ })
539
+
540
+ return backups
541
+
542
+
543
+ def main():
544
+ """CLI entry point."""
545
+ import argparse
546
+
547
+ parser = argparse.ArgumentParser(description="Stravinsky Update Manager")
548
+ parser.add_argument("--dry-run", action="store_true", help="Don't make actual changes")
549
+ parser.add_argument("--verbose", action="store_true", help="Verbose logging")
550
+ parser.add_argument("--verify", action="store_true", help="Verify integrity")
551
+ parser.add_argument("--list-backups", action="store_true", help="List backups")
552
+ parser.add_argument("--rollback", type=str, help="Rollback to backup")
553
+
554
+ args = parser.parse_args()
555
+
556
+ manager = UpdateManager(dry_run=args.dry_run, verbose=args.verbose)
557
+
558
+ if args.verify:
559
+ is_valid, issues = manager.verify_integrity()
560
+ print(f"Integrity: {'✓ Valid' if is_valid else '✗ Invalid'}")
561
+ for issue in issues:
562
+ print(f" - {issue}")
563
+ return 0 if is_valid else 1
564
+
565
+ if args.list_backups:
566
+ backups = manager.list_backups()
567
+ if not backups:
568
+ print("No backups found")
569
+ else:
570
+ print(f"Found {len(backups)} backups:")
571
+ for backup in backups:
572
+ print(f" {backup['name']} ({backup['size_mb']:.1f} MB)")
573
+ return 0
574
+
575
+ if args.rollback:
576
+ success = manager.rollback(args.rollback)
577
+ print(f"Rollback: {'✓ Success' if success else '✗ Failed'}")
578
+ return 0 if success else 1
579
+
580
+ print("Use --verify, --list-backups, or --rollback")
581
+ return 0
582
+
583
+
584
+ if __name__ == "__main__":
585
+ sys.exit(main())