ai-agent-rules 0.11.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.
Potentially problematic release.
This version of ai-agent-rules might be problematic. Click here for more details.
- ai_agent_rules-0.11.0.dist-info/METADATA +390 -0
- ai_agent_rules-0.11.0.dist-info/RECORD +42 -0
- ai_agent_rules-0.11.0.dist-info/WHEEL +5 -0
- ai_agent_rules-0.11.0.dist-info/entry_points.txt +3 -0
- ai_agent_rules-0.11.0.dist-info/licenses/LICENSE +22 -0
- ai_agent_rules-0.11.0.dist-info/top_level.txt +1 -0
- ai_rules/__init__.py +8 -0
- ai_rules/agents/__init__.py +1 -0
- ai_rules/agents/base.py +68 -0
- ai_rules/agents/claude.py +121 -0
- ai_rules/agents/goose.py +44 -0
- ai_rules/agents/shared.py +35 -0
- ai_rules/bootstrap/__init__.py +75 -0
- ai_rules/bootstrap/config.py +261 -0
- ai_rules/bootstrap/installer.py +249 -0
- ai_rules/bootstrap/updater.py +221 -0
- ai_rules/bootstrap/version.py +52 -0
- ai_rules/cli.py +2292 -0
- ai_rules/completions.py +194 -0
- ai_rules/config/AGENTS.md +249 -0
- ai_rules/config/chat_agent_hints.md +1 -0
- ai_rules/config/claude/agents/code-reviewer.md +121 -0
- ai_rules/config/claude/commands/annotate-changelog.md +191 -0
- ai_rules/config/claude/commands/comment-cleanup.md +161 -0
- ai_rules/config/claude/commands/continue-crash.md +38 -0
- ai_rules/config/claude/commands/dev-docs.md +169 -0
- ai_rules/config/claude/commands/pr-creator.md +247 -0
- ai_rules/config/claude/commands/test-cleanup.md +244 -0
- ai_rules/config/claude/commands/update-docs.md +324 -0
- ai_rules/config/claude/hooks/subagentStop.py +92 -0
- ai_rules/config/claude/mcps.json +1 -0
- ai_rules/config/claude/settings.json +116 -0
- ai_rules/config/claude/skills/doc-writer/SKILL.md +293 -0
- ai_rules/config/claude/skills/doc-writer/resources/templates.md +495 -0
- ai_rules/config/claude/skills/prompt-engineer/SKILL.md +272 -0
- ai_rules/config/claude/skills/prompt-engineer/resources/prompt_engineering_guide_2025.md +855 -0
- ai_rules/config/claude/skills/prompt-engineer/resources/templates.md +232 -0
- ai_rules/config/goose/config.yaml +55 -0
- ai_rules/config.py +635 -0
- ai_rules/display.py +40 -0
- ai_rules/mcp.py +370 -0
- ai_rules/symlinks.py +207 -0
ai_rules/mcp.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""MCP server management."""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
6
|
+
import tempfile
|
|
7
|
+
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from functools import cached_property
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, cast
|
|
13
|
+
|
|
14
|
+
from .config import Config
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OperationResult(Enum):
|
|
18
|
+
CREATED = "created"
|
|
19
|
+
UPDATED = "updated"
|
|
20
|
+
REMOVED = "removed"
|
|
21
|
+
ALREADY_SYNCED = "already_synced"
|
|
22
|
+
NOT_FOUND = "not_found"
|
|
23
|
+
ERROR = "error"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MCPStatus:
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self.managed_mcps: dict[str, dict[str, Any]] = {}
|
|
29
|
+
self.unmanaged_mcps: dict[str, dict[str, Any]] = {}
|
|
30
|
+
self.pending_mcps: dict[str, dict[str, Any]] = {}
|
|
31
|
+
self.stale_mcps: dict[str, dict[str, Any]] = {}
|
|
32
|
+
self.synced: dict[str, bool] = {}
|
|
33
|
+
self.has_overrides: dict[str, bool] = {}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
MANAGED_BY_KEY = "_managedBy"
|
|
37
|
+
MANAGED_BY_VALUE = "ai-rules"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MCPManager:
|
|
41
|
+
BACKUP_SUFFIX = "ai-rules-backup"
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def CLAUDE_JSON(self) -> Path:
|
|
45
|
+
return Path.home() / ".claude.json"
|
|
46
|
+
|
|
47
|
+
def load_managed_mcps(self, config_dir: Path, config: Config) -> dict[str, Any]:
|
|
48
|
+
"""Load managed MCP definitions and apply user overrides.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
config_dir: Config directory path
|
|
52
|
+
config: Config instance with user overrides
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Dictionary of MCP name -> MCP config (with overrides applied)
|
|
56
|
+
"""
|
|
57
|
+
mcps_file = config_dir / "claude" / "mcps.json"
|
|
58
|
+
if not mcps_file.exists():
|
|
59
|
+
return {}
|
|
60
|
+
|
|
61
|
+
with open(mcps_file) as f:
|
|
62
|
+
base_mcps = json.load(f)
|
|
63
|
+
|
|
64
|
+
mcp_overrides = config.mcp_overrides if hasattr(config, "mcp_overrides") else {}
|
|
65
|
+
|
|
66
|
+
merged_mcps = {}
|
|
67
|
+
for name, _mcp_config in {**base_mcps, **mcp_overrides}.items():
|
|
68
|
+
if name in base_mcps and name in mcp_overrides:
|
|
69
|
+
merged_mcps[name] = Config._deep_merge(
|
|
70
|
+
base_mcps[name], mcp_overrides[name]
|
|
71
|
+
)
|
|
72
|
+
elif name in base_mcps:
|
|
73
|
+
merged_mcps[name] = copy.deepcopy(base_mcps[name])
|
|
74
|
+
else:
|
|
75
|
+
merged_mcps[name] = copy.deepcopy(mcp_overrides[name])
|
|
76
|
+
|
|
77
|
+
return merged_mcps
|
|
78
|
+
|
|
79
|
+
@cached_property
|
|
80
|
+
def claude_json(self) -> dict[str, Any]:
|
|
81
|
+
"""Cached ~/.claude.json file contents.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Dictionary containing Claude Code config
|
|
85
|
+
"""
|
|
86
|
+
if not self.CLAUDE_JSON.exists():
|
|
87
|
+
return {}
|
|
88
|
+
|
|
89
|
+
with open(self.CLAUDE_JSON) as f:
|
|
90
|
+
return cast(dict[str, Any], json.load(f))
|
|
91
|
+
|
|
92
|
+
def invalidate_cache(self) -> None:
|
|
93
|
+
"""Clear cached claude_json after writes to ensure fresh reads."""
|
|
94
|
+
if "claude_json" in self.__dict__:
|
|
95
|
+
del self.__dict__["claude_json"]
|
|
96
|
+
|
|
97
|
+
def save_claude_json(self, data: dict[str, Any]) -> None:
|
|
98
|
+
"""Save ~/.claude.json file atomically.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
data: Dictionary to save
|
|
102
|
+
"""
|
|
103
|
+
self.CLAUDE_JSON.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
|
|
105
|
+
fd, temp_path = tempfile.mkstemp(
|
|
106
|
+
dir=self.CLAUDE_JSON.parent, prefix=f".{self.CLAUDE_JSON.name}."
|
|
107
|
+
)
|
|
108
|
+
try:
|
|
109
|
+
with open(fd, "w") as f:
|
|
110
|
+
json.dump(data, f, indent=2)
|
|
111
|
+
|
|
112
|
+
if self.CLAUDE_JSON.exists():
|
|
113
|
+
shutil.copystat(self.CLAUDE_JSON, temp_path)
|
|
114
|
+
|
|
115
|
+
shutil.move(temp_path, self.CLAUDE_JSON)
|
|
116
|
+
|
|
117
|
+
# Invalidate cache after write
|
|
118
|
+
self.invalidate_cache()
|
|
119
|
+
except Exception:
|
|
120
|
+
if Path(temp_path).exists():
|
|
121
|
+
Path(temp_path).unlink()
|
|
122
|
+
raise
|
|
123
|
+
|
|
124
|
+
def create_backup(self) -> Path | None:
|
|
125
|
+
"""Create a timestamped backup of ~/.claude.json.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Path to backup file, or None if source doesn't exist
|
|
129
|
+
"""
|
|
130
|
+
if not self.CLAUDE_JSON.exists():
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
134
|
+
backup_path = self.CLAUDE_JSON.with_suffix(
|
|
135
|
+
f"{self.CLAUDE_JSON.suffix}.{self.BACKUP_SUFFIX}.{timestamp}"
|
|
136
|
+
)
|
|
137
|
+
shutil.copy2(self.CLAUDE_JSON, backup_path)
|
|
138
|
+
return backup_path
|
|
139
|
+
|
|
140
|
+
def detect_conflicts(
|
|
141
|
+
self, expected: dict[str, Any], installed: dict[str, Any]
|
|
142
|
+
) -> list[str]:
|
|
143
|
+
"""Detect MCPs that have been modified locally.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
expected: Expected MCP configs from repo
|
|
147
|
+
installed: Currently installed MCP configs
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
List of MCP names that differ
|
|
151
|
+
"""
|
|
152
|
+
conflicts = []
|
|
153
|
+
for name, expected_config in expected.items():
|
|
154
|
+
if name in installed:
|
|
155
|
+
expected_without_marker = {
|
|
156
|
+
k: v for k, v in expected_config.items() if k != MANAGED_BY_KEY
|
|
157
|
+
}
|
|
158
|
+
installed_without_marker = {
|
|
159
|
+
k: v for k, v in installed[name].items() if k != MANAGED_BY_KEY
|
|
160
|
+
}
|
|
161
|
+
if expected_without_marker != installed_without_marker:
|
|
162
|
+
conflicts.append(name)
|
|
163
|
+
return conflicts
|
|
164
|
+
|
|
165
|
+
def format_diff(
|
|
166
|
+
self, name: str, expected: dict[str, Any], installed: dict[str, Any]
|
|
167
|
+
) -> str:
|
|
168
|
+
"""Format a diff between expected and installed MCP config.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
name: MCP name
|
|
172
|
+
expected: Expected config
|
|
173
|
+
installed: Installed config
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Formatted diff string
|
|
177
|
+
"""
|
|
178
|
+
import difflib
|
|
179
|
+
|
|
180
|
+
expected_json = json.dumps(expected, indent=2)
|
|
181
|
+
installed_json = json.dumps(installed, indent=2)
|
|
182
|
+
|
|
183
|
+
expected_lines = expected_json.splitlines(keepends=True)
|
|
184
|
+
installed_lines = installed_json.splitlines(keepends=True)
|
|
185
|
+
|
|
186
|
+
diff = difflib.unified_diff(
|
|
187
|
+
expected_lines,
|
|
188
|
+
installed_lines,
|
|
189
|
+
fromfile="Expected (repo)",
|
|
190
|
+
tofile="Installed (local)",
|
|
191
|
+
lineterm="",
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
return f"MCP '{name}' has been modified locally:\n" + "".join(diff)
|
|
195
|
+
|
|
196
|
+
def install_mcps(
|
|
197
|
+
self,
|
|
198
|
+
config_dir: Path,
|
|
199
|
+
config: Config,
|
|
200
|
+
force: bool = False,
|
|
201
|
+
dry_run: bool = False,
|
|
202
|
+
) -> tuple[OperationResult, str, list[str]]:
|
|
203
|
+
"""Install managed MCPs into ~/.claude.json.
|
|
204
|
+
|
|
205
|
+
Auto-removes MCPs that were previously tracked but are no longer in current config.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
config_dir: Config directory path
|
|
209
|
+
config: Config instance with user overrides
|
|
210
|
+
force: Skip confirmation prompts
|
|
211
|
+
dry_run: Don't actually modify files
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Tuple of (result, message, conflicts_list)
|
|
215
|
+
"""
|
|
216
|
+
managed_mcps = self.load_managed_mcps(config_dir, config)
|
|
217
|
+
|
|
218
|
+
for _name, mcp_config in managed_mcps.items():
|
|
219
|
+
mcp_config[MANAGED_BY_KEY] = MANAGED_BY_VALUE
|
|
220
|
+
|
|
221
|
+
claude_data = self.claude_json
|
|
222
|
+
current_mcps = claude_data.get("mcpServers", {})
|
|
223
|
+
|
|
224
|
+
tracked_mcps = {
|
|
225
|
+
name
|
|
226
|
+
for name, cfg in current_mcps.items()
|
|
227
|
+
if cfg.get(MANAGED_BY_KEY) == MANAGED_BY_VALUE
|
|
228
|
+
}
|
|
229
|
+
removed_mcps = tracked_mcps - set(managed_mcps.keys())
|
|
230
|
+
|
|
231
|
+
if not managed_mcps and not removed_mcps:
|
|
232
|
+
return (
|
|
233
|
+
OperationResult.NOT_FOUND,
|
|
234
|
+
"No MCPs to install or remove",
|
|
235
|
+
[],
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
conflicts = self.detect_conflicts(managed_mcps, current_mcps)
|
|
239
|
+
|
|
240
|
+
if conflicts and not force:
|
|
241
|
+
return (
|
|
242
|
+
OperationResult.ERROR,
|
|
243
|
+
"Conflicts detected (use --force to override)",
|
|
244
|
+
conflicts,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
if dry_run:
|
|
248
|
+
msg = f"Would update {len(managed_mcps)} MCPs, remove {len(removed_mcps)}"
|
|
249
|
+
return (OperationResult.UPDATED, msg, conflicts)
|
|
250
|
+
|
|
251
|
+
if not managed_mcps and not removed_mcps:
|
|
252
|
+
new_tracking = set(managed_mcps.keys())
|
|
253
|
+
if tracked_mcps == new_tracking:
|
|
254
|
+
return (OperationResult.ALREADY_SYNCED, "MCPs are already synced", [])
|
|
255
|
+
|
|
256
|
+
backup_path = self.create_backup() if (managed_mcps or removed_mcps) else None
|
|
257
|
+
|
|
258
|
+
for name in removed_mcps:
|
|
259
|
+
current_mcps.pop(name, None)
|
|
260
|
+
|
|
261
|
+
claude_data.setdefault("mcpServers", {})
|
|
262
|
+
claude_data["mcpServers"].update(managed_mcps)
|
|
263
|
+
|
|
264
|
+
self.save_claude_json(claude_data)
|
|
265
|
+
|
|
266
|
+
parts = []
|
|
267
|
+
if managed_mcps:
|
|
268
|
+
parts.append(f"installed {len(managed_mcps)}")
|
|
269
|
+
if removed_mcps:
|
|
270
|
+
parts.append(f"removed {len(removed_mcps)}")
|
|
271
|
+
msg = f"MCPs {', '.join(parts)}"
|
|
272
|
+
if backup_path:
|
|
273
|
+
msg += f" (backup: {backup_path})"
|
|
274
|
+
|
|
275
|
+
return (OperationResult.UPDATED, msg, [])
|
|
276
|
+
|
|
277
|
+
def uninstall_mcps(
|
|
278
|
+
self, force: bool = False, dry_run: bool = False
|
|
279
|
+
) -> tuple[OperationResult, str]:
|
|
280
|
+
"""Uninstall managed MCPs from ~/.claude.json.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
force: Skip confirmation prompts
|
|
284
|
+
dry_run: Don't actually modify files
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
Tuple of (result, message)
|
|
288
|
+
"""
|
|
289
|
+
claude_data = self.claude_json
|
|
290
|
+
if not claude_data or "mcpServers" not in claude_data:
|
|
291
|
+
return (OperationResult.NOT_FOUND, "No MCPs found in ~/.claude.json")
|
|
292
|
+
|
|
293
|
+
current_mcps = claude_data["mcpServers"]
|
|
294
|
+
tracked_mcps = {
|
|
295
|
+
name
|
|
296
|
+
for name, cfg in current_mcps.items()
|
|
297
|
+
if cfg.get(MANAGED_BY_KEY) == MANAGED_BY_VALUE
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if not tracked_mcps:
|
|
301
|
+
return (OperationResult.NOT_FOUND, "No tracked MCPs found")
|
|
302
|
+
|
|
303
|
+
if dry_run:
|
|
304
|
+
return (
|
|
305
|
+
OperationResult.REMOVED,
|
|
306
|
+
f"Would remove {len(tracked_mcps)} MCPs (dry run)",
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
backup_path = self.create_backup()
|
|
310
|
+
|
|
311
|
+
for name in tracked_mcps:
|
|
312
|
+
current_mcps.pop(name, None)
|
|
313
|
+
|
|
314
|
+
self.save_claude_json(claude_data)
|
|
315
|
+
|
|
316
|
+
backup_msg = f" (backup: {backup_path})" if backup_path else ""
|
|
317
|
+
return (
|
|
318
|
+
OperationResult.REMOVED,
|
|
319
|
+
f"Removed {len(tracked_mcps)} MCPs{backup_msg}",
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
def get_status(self, config_dir: Path, config: Config) -> MCPStatus:
|
|
323
|
+
"""Get status of managed and unmanaged MCPs.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
config_dir: Config directory path
|
|
327
|
+
config: Config instance
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
MCPStatus object with categorized MCPs
|
|
331
|
+
"""
|
|
332
|
+
self.invalidate_cache()
|
|
333
|
+
|
|
334
|
+
status = MCPStatus()
|
|
335
|
+
managed_mcps = self.load_managed_mcps(config_dir, config)
|
|
336
|
+
|
|
337
|
+
for _name, mcp_config in managed_mcps.items():
|
|
338
|
+
mcp_config[MANAGED_BY_KEY] = MANAGED_BY_VALUE
|
|
339
|
+
|
|
340
|
+
claude_data = self.claude_json
|
|
341
|
+
installed_mcps = claude_data.get("mcpServers", {})
|
|
342
|
+
|
|
343
|
+
mcp_overrides = config.mcp_overrides if hasattr(config, "mcp_overrides") else {}
|
|
344
|
+
|
|
345
|
+
for name, mcp_config in installed_mcps.items():
|
|
346
|
+
if mcp_config.get(MANAGED_BY_KEY) == MANAGED_BY_VALUE:
|
|
347
|
+
if name in managed_mcps:
|
|
348
|
+
status.managed_mcps[name] = mcp_config
|
|
349
|
+
expected_config = managed_mcps.get(name, {})
|
|
350
|
+
expected_without_marker = {
|
|
351
|
+
k: v for k, v in expected_config.items() if k != MANAGED_BY_KEY
|
|
352
|
+
}
|
|
353
|
+
installed_without_marker = {
|
|
354
|
+
k: v for k, v in mcp_config.items() if k != MANAGED_BY_KEY
|
|
355
|
+
}
|
|
356
|
+
status.synced[name] = (
|
|
357
|
+
expected_without_marker == installed_without_marker
|
|
358
|
+
)
|
|
359
|
+
status.has_overrides[name] = name in mcp_overrides
|
|
360
|
+
else:
|
|
361
|
+
status.stale_mcps[name] = mcp_config
|
|
362
|
+
else:
|
|
363
|
+
status.unmanaged_mcps[name] = mcp_config
|
|
364
|
+
|
|
365
|
+
for name, mcp_config in managed_mcps.items():
|
|
366
|
+
if name not in installed_mcps:
|
|
367
|
+
status.pending_mcps[name] = mcp_config
|
|
368
|
+
status.has_overrides[name] = name in mcp_overrides
|
|
369
|
+
|
|
370
|
+
return status
|
ai_rules/symlinks.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Symlink operations with safety checks."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def create_backup_path(target: Path) -> Path:
|
|
15
|
+
"""Create a timestamped backup path.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
target: The file to backup
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
Path with timestamp appended (e.g., file.md.ai-rules-backup.20250104-143022)
|
|
22
|
+
"""
|
|
23
|
+
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
24
|
+
return Path(f"{target}.ai-rules-backup.{timestamp}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SymlinkResult(Enum):
|
|
28
|
+
"""Result of symlink operation."""
|
|
29
|
+
|
|
30
|
+
CREATED = "created"
|
|
31
|
+
ALREADY_CORRECT = "already_correct"
|
|
32
|
+
UPDATED = "updated"
|
|
33
|
+
SKIPPED = "skipped"
|
|
34
|
+
ERROR = "error"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def create_symlink(
|
|
38
|
+
target_path: Path,
|
|
39
|
+
source_path: Path,
|
|
40
|
+
force: bool = False,
|
|
41
|
+
dry_run: bool = False,
|
|
42
|
+
) -> tuple[SymlinkResult, str]:
|
|
43
|
+
"""Create a symlink with safety checks.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
target_path: Where the symlink should be created (e.g., ~/.CLAUDE.md)
|
|
47
|
+
source_path: What the symlink should point to (e.g., repo/config/AGENTS.md)
|
|
48
|
+
force: Skip confirmations
|
|
49
|
+
dry_run: Don't actually create symlinks
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Tuple of (result, message)
|
|
53
|
+
"""
|
|
54
|
+
target = target_path.expanduser()
|
|
55
|
+
source = source_path.absolute()
|
|
56
|
+
|
|
57
|
+
if not source.exists():
|
|
58
|
+
return (
|
|
59
|
+
SymlinkResult.ERROR,
|
|
60
|
+
f"Source file does not exist: {source}",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if target.exists() or target.is_symlink():
|
|
64
|
+
if target.is_symlink():
|
|
65
|
+
current = target.resolve()
|
|
66
|
+
if current == source:
|
|
67
|
+
return (SymlinkResult.ALREADY_CORRECT, "Already correct")
|
|
68
|
+
elif dry_run:
|
|
69
|
+
return (SymlinkResult.UPDATED, f"Would update: {current} → {source}")
|
|
70
|
+
elif force:
|
|
71
|
+
target.unlink()
|
|
72
|
+
else:
|
|
73
|
+
response = console.input(
|
|
74
|
+
f"[yellow]?[/yellow] Symlink {target} exists but points to {current}\n Replace with {source}? (y/N): "
|
|
75
|
+
)
|
|
76
|
+
if response.lower() != "y":
|
|
77
|
+
return (SymlinkResult.SKIPPED, "Skipped by user")
|
|
78
|
+
target.unlink()
|
|
79
|
+
else:
|
|
80
|
+
if dry_run:
|
|
81
|
+
return (
|
|
82
|
+
SymlinkResult.CREATED,
|
|
83
|
+
f"Would backup {target} and create symlink",
|
|
84
|
+
)
|
|
85
|
+
elif force:
|
|
86
|
+
backup = create_backup_path(target)
|
|
87
|
+
target.rename(backup)
|
|
88
|
+
console.print(f" [dim]Backed up to {backup}[/dim]")
|
|
89
|
+
else:
|
|
90
|
+
response = console.input(
|
|
91
|
+
f"[yellow]?[/yellow] File {target} exists and is not a symlink\n Replace with symlink? (y/N): "
|
|
92
|
+
)
|
|
93
|
+
if response.lower() != "y":
|
|
94
|
+
return (SymlinkResult.SKIPPED, "Skipped by user")
|
|
95
|
+
backup = create_backup_path(target)
|
|
96
|
+
target.rename(backup)
|
|
97
|
+
console.print(f" [dim]Backed up to {backup}[/dim]")
|
|
98
|
+
|
|
99
|
+
if dry_run:
|
|
100
|
+
return (SymlinkResult.CREATED, f"Would create: {target} → {source}")
|
|
101
|
+
|
|
102
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
rel_source = os.path.relpath(source, target.parent)
|
|
106
|
+
target.symlink_to(rel_source)
|
|
107
|
+
return (SymlinkResult.CREATED, "Created")
|
|
108
|
+
except PermissionError as e:
|
|
109
|
+
return (
|
|
110
|
+
SymlinkResult.ERROR,
|
|
111
|
+
f"Permission denied: {e}\n"
|
|
112
|
+
" [dim]Tip: Check file permissions and ownership. "
|
|
113
|
+
"You may need to remove existing files manually.[/dim]",
|
|
114
|
+
)
|
|
115
|
+
except FileExistsError as e:
|
|
116
|
+
return (
|
|
117
|
+
SymlinkResult.ERROR,
|
|
118
|
+
f"File already exists: {e}\n"
|
|
119
|
+
" [dim]Tip: Use --force to replace existing files.[/dim]",
|
|
120
|
+
)
|
|
121
|
+
except (OSError, ValueError) as e:
|
|
122
|
+
try:
|
|
123
|
+
target.symlink_to(source)
|
|
124
|
+
return (SymlinkResult.CREATED, "Created (absolute path)")
|
|
125
|
+
except PermissionError:
|
|
126
|
+
return (
|
|
127
|
+
SymlinkResult.ERROR,
|
|
128
|
+
f"Permission denied: {e}\n"
|
|
129
|
+
" [dim]Tip: Check file permissions and ownership.[/dim]",
|
|
130
|
+
)
|
|
131
|
+
except Exception as e2:
|
|
132
|
+
return (
|
|
133
|
+
SymlinkResult.ERROR,
|
|
134
|
+
f"Failed to create symlink: {e2}\n"
|
|
135
|
+
" [dim]Tip: Check that the target directory exists and is writable.[/dim]",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def check_symlink(target_path: Path, expected_source: Path) -> tuple[str, str]:
|
|
140
|
+
"""Check if a symlink is correct.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
Tuple of (status, message) where status is one of:
|
|
144
|
+
- "correct": Symlink exists and points to correct location
|
|
145
|
+
- "missing": Symlink does not exist
|
|
146
|
+
- "broken": Symlink exists but target doesn't exist
|
|
147
|
+
- "wrong_target": Symlink points to wrong location
|
|
148
|
+
- "not_symlink": File exists but is not a symlink
|
|
149
|
+
"""
|
|
150
|
+
target = target_path.expanduser()
|
|
151
|
+
expected = expected_source.absolute()
|
|
152
|
+
|
|
153
|
+
if not target.exists() and not target.is_symlink():
|
|
154
|
+
return ("missing", "Not installed")
|
|
155
|
+
|
|
156
|
+
if not target.is_symlink():
|
|
157
|
+
return ("not_symlink", "File exists but is not a symlink")
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
actual = target.resolve()
|
|
161
|
+
except (OSError, RuntimeError):
|
|
162
|
+
return ("broken", "Symlink is broken")
|
|
163
|
+
|
|
164
|
+
if actual == expected:
|
|
165
|
+
return ("correct", str(expected))
|
|
166
|
+
else:
|
|
167
|
+
return ("wrong_target", f"Points to {actual} instead of {expected}")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def remove_symlink(target_path: Path, force: bool = False) -> tuple[bool, str]:
|
|
171
|
+
"""Remove a symlink safely.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
target_path: Path to the symlink to remove
|
|
175
|
+
force: Skip confirmation
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Tuple of (success, message)
|
|
179
|
+
"""
|
|
180
|
+
target = target_path.expanduser()
|
|
181
|
+
|
|
182
|
+
if not target.exists() and not target.is_symlink():
|
|
183
|
+
return (False, "Does not exist")
|
|
184
|
+
|
|
185
|
+
if not target.is_symlink():
|
|
186
|
+
return (False, "Not a symlink (refusing to delete)")
|
|
187
|
+
|
|
188
|
+
if not force:
|
|
189
|
+
response = console.input(f"[yellow]?[/yellow] Remove {target}? (y/N): ")
|
|
190
|
+
if response.lower() != "y":
|
|
191
|
+
return (False, "Skipped by user")
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
target.unlink()
|
|
195
|
+
return (True, "Removed")
|
|
196
|
+
except PermissionError as e:
|
|
197
|
+
return (
|
|
198
|
+
False,
|
|
199
|
+
f"Permission denied: {e}\n"
|
|
200
|
+
" [dim]Tip: Check file permissions. You may need elevated privileges.[/dim]",
|
|
201
|
+
)
|
|
202
|
+
except OSError as e:
|
|
203
|
+
return (
|
|
204
|
+
False,
|
|
205
|
+
f"Error removing symlink: {e}\n"
|
|
206
|
+
" [dim]Tip: Check that the file exists and is accessible.[/dim]",
|
|
207
|
+
)
|