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.
- mcp_bridge/__init__.py +1 -1
- mcp_bridge/auth/__init__.py +16 -6
- mcp_bridge/auth/cli.py +202 -11
- mcp_bridge/auth/oauth.py +1 -2
- mcp_bridge/auth/openai_oauth.py +4 -7
- mcp_bridge/auth/token_store.py +112 -11
- mcp_bridge/cli/__init__.py +1 -1
- mcp_bridge/cli/install_hooks.py +503 -107
- mcp_bridge/cli/session_report.py +0 -3
- mcp_bridge/config/MANIFEST_SCHEMA.md +305 -0
- mcp_bridge/config/README.md +276 -0
- mcp_bridge/config/__init__.py +2 -2
- mcp_bridge/config/hook_config.py +247 -0
- mcp_bridge/config/hooks_manifest.json +138 -0
- mcp_bridge/config/rate_limits.py +317 -0
- mcp_bridge/config/skills_manifest.json +128 -0
- mcp_bridge/hooks/HOOKS_SETTINGS.json +17 -4
- mcp_bridge/hooks/__init__.py +19 -4
- mcp_bridge/hooks/agent_reminder.py +4 -4
- mcp_bridge/hooks/auto_slash_command.py +5 -5
- mcp_bridge/hooks/budget_optimizer.py +2 -2
- mcp_bridge/hooks/claude_limits_hook.py +114 -0
- mcp_bridge/hooks/comment_checker.py +3 -4
- mcp_bridge/hooks/compaction.py +2 -2
- mcp_bridge/hooks/context.py +2 -1
- mcp_bridge/hooks/context_monitor.py +2 -2
- mcp_bridge/hooks/delegation_policy.py +85 -0
- mcp_bridge/hooks/directory_context.py +3 -3
- mcp_bridge/hooks/edit_recovery.py +3 -2
- mcp_bridge/hooks/edit_recovery_policy.py +49 -0
- mcp_bridge/hooks/empty_message_sanitizer.py +2 -2
- mcp_bridge/hooks/events.py +160 -0
- mcp_bridge/hooks/git_noninteractive.py +4 -4
- mcp_bridge/hooks/keyword_detector.py +8 -10
- mcp_bridge/hooks/manager.py +43 -22
- mcp_bridge/hooks/notification_hook.py +13 -6
- mcp_bridge/hooks/parallel_enforcement_policy.py +67 -0
- mcp_bridge/hooks/parallel_enforcer.py +5 -5
- mcp_bridge/hooks/parallel_execution.py +22 -10
- mcp_bridge/hooks/post_tool/parallel_validation.py +103 -0
- mcp_bridge/hooks/pre_compact.py +8 -9
- mcp_bridge/hooks/pre_tool/agent_spawn_validator.py +115 -0
- mcp_bridge/hooks/preemptive_compaction.py +2 -3
- mcp_bridge/hooks/routing_notifications.py +80 -0
- mcp_bridge/hooks/rules_injector.py +11 -19
- mcp_bridge/hooks/session_idle.py +4 -4
- mcp_bridge/hooks/session_notifier.py +4 -4
- mcp_bridge/hooks/session_recovery.py +4 -5
- mcp_bridge/hooks/stravinsky_mode.py +1 -1
- mcp_bridge/hooks/subagent_stop.py +1 -3
- mcp_bridge/hooks/task_validator.py +2 -2
- mcp_bridge/hooks/tmux_manager.py +7 -8
- mcp_bridge/hooks/todo_delegation.py +4 -1
- mcp_bridge/hooks/todo_enforcer.py +180 -10
- mcp_bridge/hooks/tool_messaging.py +113 -10
- mcp_bridge/hooks/truncation_policy.py +37 -0
- mcp_bridge/hooks/truncator.py +1 -2
- mcp_bridge/metrics/cost_tracker.py +115 -0
- mcp_bridge/native_search.py +93 -0
- mcp_bridge/native_watcher.py +118 -0
- mcp_bridge/notifications.py +150 -0
- mcp_bridge/orchestrator/enums.py +11 -0
- mcp_bridge/orchestrator/router.py +165 -0
- mcp_bridge/orchestrator/state.py +32 -0
- mcp_bridge/orchestrator/visualization.py +14 -0
- mcp_bridge/orchestrator/wisdom.py +34 -0
- mcp_bridge/prompts/__init__.py +1 -8
- mcp_bridge/prompts/dewey.py +1 -1
- mcp_bridge/prompts/planner.py +2 -4
- mcp_bridge/prompts/stravinsky.py +53 -31
- mcp_bridge/proxy/__init__.py +0 -0
- mcp_bridge/proxy/client.py +70 -0
- mcp_bridge/proxy/model_server.py +157 -0
- mcp_bridge/routing/__init__.py +43 -0
- mcp_bridge/routing/config.py +250 -0
- mcp_bridge/routing/model_tiers.py +135 -0
- mcp_bridge/routing/provider_state.py +261 -0
- mcp_bridge/routing/task_classifier.py +190 -0
- mcp_bridge/server.py +542 -59
- mcp_bridge/server_tools.py +738 -6
- mcp_bridge/tools/__init__.py +40 -25
- mcp_bridge/tools/agent_manager.py +616 -697
- mcp_bridge/tools/background_tasks.py +13 -17
- mcp_bridge/tools/code_search.py +70 -53
- mcp_bridge/tools/continuous_loop.py +0 -1
- mcp_bridge/tools/dashboard.py +19 -0
- mcp_bridge/tools/find_code.py +296 -0
- mcp_bridge/tools/init.py +1 -0
- mcp_bridge/tools/list_directory.py +42 -0
- mcp_bridge/tools/lsp/__init__.py +12 -5
- mcp_bridge/tools/lsp/manager.py +471 -0
- mcp_bridge/tools/lsp/tools.py +723 -207
- mcp_bridge/tools/model_invoke.py +1195 -273
- mcp_bridge/tools/mux_client.py +75 -0
- mcp_bridge/tools/project_context.py +1 -2
- mcp_bridge/tools/query_classifier.py +406 -0
- mcp_bridge/tools/read_file.py +84 -0
- mcp_bridge/tools/replace.py +45 -0
- mcp_bridge/tools/run_shell_command.py +38 -0
- mcp_bridge/tools/search_enhancements.py +347 -0
- mcp_bridge/tools/semantic_search.py +3627 -0
- mcp_bridge/tools/session_manager.py +0 -2
- mcp_bridge/tools/skill_loader.py +0 -1
- mcp_bridge/tools/task_runner.py +5 -7
- mcp_bridge/tools/templates.py +3 -3
- mcp_bridge/tools/tool_search.py +331 -0
- mcp_bridge/tools/write_file.py +29 -0
- mcp_bridge/update_manager.py +585 -0
- mcp_bridge/update_manager_pypi.py +297 -0
- mcp_bridge/utils/cache.py +82 -0
- mcp_bridge/utils/process.py +71 -0
- mcp_bridge/utils/session_state.py +51 -0
- mcp_bridge/utils/truncation.py +76 -0
- stravinsky-0.4.66.dist-info/METADATA +517 -0
- stravinsky-0.4.66.dist-info/RECORD +198 -0
- {stravinsky-0.2.67.dist-info → stravinsky-0.4.66.dist-info}/entry_points.txt +1 -0
- stravinsky_claude_assets/HOOKS_INTEGRATION.md +316 -0
- stravinsky_claude_assets/agents/HOOKS.md +437 -0
- stravinsky_claude_assets/agents/code-reviewer.md +210 -0
- stravinsky_claude_assets/agents/comment_checker.md +580 -0
- stravinsky_claude_assets/agents/debugger.md +254 -0
- stravinsky_claude_assets/agents/delphi.md +495 -0
- stravinsky_claude_assets/agents/dewey.md +248 -0
- stravinsky_claude_assets/agents/explore.md +1198 -0
- stravinsky_claude_assets/agents/frontend.md +472 -0
- stravinsky_claude_assets/agents/implementation-lead.md +164 -0
- stravinsky_claude_assets/agents/momus.md +464 -0
- stravinsky_claude_assets/agents/research-lead.md +141 -0
- stravinsky_claude_assets/agents/stravinsky.md +730 -0
- stravinsky_claude_assets/commands/delphi.md +9 -0
- stravinsky_claude_assets/commands/dewey.md +54 -0
- stravinsky_claude_assets/commands/git-master.md +112 -0
- stravinsky_claude_assets/commands/index.md +49 -0
- stravinsky_claude_assets/commands/publish.md +86 -0
- stravinsky_claude_assets/commands/review.md +73 -0
- stravinsky_claude_assets/commands/str/agent_cancel.md +70 -0
- stravinsky_claude_assets/commands/str/agent_list.md +56 -0
- stravinsky_claude_assets/commands/str/agent_output.md +92 -0
- stravinsky_claude_assets/commands/str/agent_progress.md +74 -0
- stravinsky_claude_assets/commands/str/agent_retry.md +94 -0
- stravinsky_claude_assets/commands/str/cancel.md +51 -0
- stravinsky_claude_assets/commands/str/clean.md +97 -0
- stravinsky_claude_assets/commands/str/continue.md +38 -0
- stravinsky_claude_assets/commands/str/index.md +199 -0
- stravinsky_claude_assets/commands/str/list_watchers.md +96 -0
- stravinsky_claude_assets/commands/str/search.md +205 -0
- stravinsky_claude_assets/commands/str/start_filewatch.md +136 -0
- stravinsky_claude_assets/commands/str/stats.md +71 -0
- stravinsky_claude_assets/commands/str/stop_filewatch.md +89 -0
- stravinsky_claude_assets/commands/str/unwatch.md +42 -0
- stravinsky_claude_assets/commands/str/watch.md +45 -0
- stravinsky_claude_assets/commands/strav.md +53 -0
- stravinsky_claude_assets/commands/stravinsky.md +292 -0
- stravinsky_claude_assets/commands/verify.md +60 -0
- stravinsky_claude_assets/commands/version.md +5 -0
- stravinsky_claude_assets/hooks/README.md +248 -0
- stravinsky_claude_assets/hooks/comment_checker.py +193 -0
- stravinsky_claude_assets/hooks/context.py +38 -0
- stravinsky_claude_assets/hooks/context_monitor.py +153 -0
- stravinsky_claude_assets/hooks/dependency_tracker.py +73 -0
- stravinsky_claude_assets/hooks/edit_recovery.py +46 -0
- stravinsky_claude_assets/hooks/execution_state_tracker.py +68 -0
- stravinsky_claude_assets/hooks/notification_hook.py +103 -0
- stravinsky_claude_assets/hooks/notification_hook_v2.py +96 -0
- stravinsky_claude_assets/hooks/parallel_execution.py +241 -0
- stravinsky_claude_assets/hooks/parallel_reinforcement.py +106 -0
- stravinsky_claude_assets/hooks/parallel_reinforcement_v2.py +112 -0
- stravinsky_claude_assets/hooks/pre_compact.py +123 -0
- stravinsky_claude_assets/hooks/ralph_loop.py +173 -0
- stravinsky_claude_assets/hooks/session_recovery.py +263 -0
- stravinsky_claude_assets/hooks/stop_hook.py +89 -0
- stravinsky_claude_assets/hooks/stravinsky_metrics.py +164 -0
- stravinsky_claude_assets/hooks/stravinsky_mode.py +146 -0
- stravinsky_claude_assets/hooks/subagent_stop.py +98 -0
- stravinsky_claude_assets/hooks/todo_continuation.py +111 -0
- stravinsky_claude_assets/hooks/todo_delegation.py +96 -0
- stravinsky_claude_assets/hooks/tool_messaging.py +281 -0
- stravinsky_claude_assets/hooks/truncator.py +23 -0
- stravinsky_claude_assets/rules/deployment_safety.md +51 -0
- stravinsky_claude_assets/rules/integration_wiring.md +89 -0
- stravinsky_claude_assets/rules/pypi_deployment.md +220 -0
- stravinsky_claude_assets/rules/stravinsky_orchestrator.md +32 -0
- stravinsky_claude_assets/settings.json +152 -0
- stravinsky_claude_assets/skills/chrome-devtools/SKILL.md +81 -0
- stravinsky_claude_assets/skills/sqlite/SKILL.md +77 -0
- stravinsky_claude_assets/skills/supabase/SKILL.md +74 -0
- stravinsky_claude_assets/task_dependencies.json +34 -0
- stravinsky-0.2.67.dist-info/METADATA +0 -284
- stravinsky-0.2.67.dist-info/RECORD +0 -76
- {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())
|