@softspark/ai-toolkit 4.14.0 → 4.15.0
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.
- package/CHANGELOG.md +35 -0
- package/README.md +11 -10
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/CLAUDE.md.template +3 -0
- package/app/agents/fact-checker.md +1 -1
- package/app/hooks/_search-capability.sh +3 -2
- package/app/hooks/stop-search-check.sh +2 -1
- package/benchmarks/ecosystem-doctor-snapshot.json +73 -31
- package/kb/procedures/maintenance-sop.md +26 -13
- package/kb/procedures/release-verification-sop.md +41 -36
- package/kb/reference/architecture-overview.md +23 -7
- package/kb/reference/codex-cli-compatibility.md +96 -36
- package/kb/reference/extension-api.md +52 -9
- package/kb/reference/global-install-model.md +53 -21
- package/kb/reference/hooks-catalog.md +44 -8
- package/kb/reference/mcp-editor-compatibility.md +27 -6
- package/kb/reference/mcp-templates.md +12 -6
- package/kb/reference/opencode-compatibility.md +13 -7
- package/kb/reference/plugin-pack-conventions.md +7 -7
- package/kb/reference/skills-catalog.md +3 -3
- package/kb/reference/supported-tools-registry.md +19 -17
- package/kb/reference/windows-support.md +26 -3
- package/llms-full.txt +443 -180
- package/llms.txt +1 -1
- package/manifest.json +1 -1
- package/package.json +2 -2
- package/scripts/codex_skill_adapter.py +448 -198
- package/scripts/dir_rules_shared.py +2 -11
- package/scripts/ecosystem_tools.json +29 -8
- package/scripts/emission.py +5 -91
- package/scripts/generate_agents_md.py +4 -87
- package/scripts/generate_codex.py +5 -95
- package/scripts/generate_codex_agents.py +242 -0
- package/scripts/generate_codex_hooks.py +648 -55
- package/scripts/generate_codex_skills.py +15 -6
- package/scripts/generate_copilot.py +771 -74
- package/scripts/generate_copilot_hooks.py +606 -0
- package/scripts/generate_cursor_hooks.py +453 -121
- package/scripts/generate_opencode_commands.py +4 -6
- package/scripts/inject_hook_cli.py +770 -205
- package/scripts/injection.py +102 -23
- package/scripts/install_steps/ai_tools.py +123 -83
- package/scripts/instruction_core.py +95 -0
- package/scripts/mcp_editors.py +934 -80
- package/scripts/mcp_manager.py +46 -26
- package/scripts/plugin.py +291 -114
- package/scripts/secure_fs.py +538 -0
- package/scripts/uninstall.py +1279 -208
package/scripts/uninstall.py
CHANGED
|
@@ -1,290 +1,1361 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
2
|
+
"""Safely remove ai-toolkit-managed runtime customizations.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
User-owned files are never removed.
|
|
4
|
+
The default scope is the current user's global install. ``--local`` targets a
|
|
5
|
+
project, while an explicit legacy positional target scans both project and
|
|
6
|
+
home-style locations for backward compatibility. Only files, symlinks, JSON
|
|
7
|
+
handlers, and marker blocks with verifiable ai-toolkit ownership are removed.
|
|
9
8
|
|
|
10
9
|
Usage:
|
|
11
|
-
python3 scripts/uninstall.py [--yes] [target
|
|
10
|
+
python3 scripts/uninstall.py [--yes] [--local|--global] [--target DIR]
|
|
11
|
+
python3 scripts/uninstall.py [--yes] [legacy-target-dir]
|
|
12
12
|
"""
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
|
+
import argparse
|
|
16
|
+
import copy
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
15
19
|
import re
|
|
20
|
+
import secrets
|
|
21
|
+
import stat
|
|
16
22
|
import subprocess
|
|
17
23
|
import sys
|
|
24
|
+
import tempfile
|
|
25
|
+
from collections.abc import Iterator
|
|
26
|
+
from contextlib import contextmanager
|
|
27
|
+
from dataclasses import dataclass
|
|
18
28
|
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
19
30
|
|
|
20
31
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
21
|
-
from _common import
|
|
32
|
+
from _common import app_dir, toolkit_dir
|
|
33
|
+
from injection import strip_all_sections, strip_section, trim_trailing_blanks
|
|
22
34
|
|
|
23
35
|
|
|
24
|
-
#
|
|
25
|
-
|
|
26
|
-
#
|
|
36
|
+
CODEX_AGENT_MARKER = "# ai-toolkit-managed: codex-agent"
|
|
37
|
+
CODEX_ADAPTED_SKILL_MARKER = ".ai-toolkit-codex-adapted"
|
|
38
|
+
CODEX_HOOK_ASSET_MARKER = "# ai-toolkit-managed: codex-hook-script"
|
|
39
|
+
COPILOT_MARKER = "<!-- ai-toolkit-managed: github-copilot -->"
|
|
40
|
+
COPILOT_SKILL_MANIFEST = ".ai-toolkit-managed-files"
|
|
41
|
+
COPILOT_HOOK_ASSET_MARKER = "# ai-toolkit-managed: github-copilot-hook"
|
|
42
|
+
HOOK_OWNER_KEY = "AI_TOOLKIT_HOOK_OWNER"
|
|
43
|
+
LEGACY_CODEX_HOOK_PATH = ".softspark/ai-toolkit/hooks/"
|
|
44
|
+
|
|
45
|
+
_TOOLKIT_SECTION_RE = re.compile(
|
|
46
|
+
r"^<!-- TOOLKIT:(?P<section>.+) START -->$",
|
|
47
|
+
re.MULTILINE,
|
|
48
|
+
)
|
|
49
|
+
_CODEX_OWNER_RE = re.compile(
|
|
50
|
+
r"(?:^|\s)AI_TOOLKIT_HOOK_OWNER=(?:['\"])?ai-toolkit(?:['\"])?(?=\s|$)"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class CodexSurface:
|
|
56
|
+
config_root: Path
|
|
57
|
+
instructions: Path
|
|
58
|
+
skills_root: Path
|
|
59
|
+
assets_root: Path
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class CopilotSurface:
|
|
64
|
+
customization_root: Path
|
|
65
|
+
instructions: Path
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class _PathSnapshot:
|
|
70
|
+
kind: str
|
|
71
|
+
mode: int
|
|
72
|
+
atime_ns: int
|
|
73
|
+
mtime_ns: int
|
|
74
|
+
trusted_root: Path
|
|
75
|
+
content: bytes | None = None
|
|
76
|
+
link_target: str | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_DIRECTORY_FLAGS = (
|
|
80
|
+
os.O_RDONLY
|
|
81
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
82
|
+
| getattr(os, "O_DIRECTORY", 0)
|
|
83
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
84
|
+
)
|
|
85
|
+
_SECURE_DIR_FD = (
|
|
86
|
+
hasattr(os, "O_DIRECTORY")
|
|
87
|
+
and hasattr(os, "O_NOFOLLOW")
|
|
88
|
+
and all(
|
|
89
|
+
function in os.supports_dir_fd
|
|
90
|
+
for function in (
|
|
91
|
+
os.open,
|
|
92
|
+
os.unlink,
|
|
93
|
+
os.rmdir,
|
|
94
|
+
os.mkdir,
|
|
95
|
+
os.rename,
|
|
96
|
+
os.stat,
|
|
97
|
+
os.readlink,
|
|
98
|
+
os.symlink,
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
_UNSAFE_MUTATION_PLATFORM_ERROR = (
|
|
103
|
+
"Safe uninstall mutations require POSIX dir_fd and O_NOFOLLOW support, "
|
|
104
|
+
"which this Python runtime does not provide. No files were changed. "
|
|
105
|
+
"On Windows, run ai-toolkit uninstall from WSL."
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _require_secure_mutation_support() -> None:
|
|
110
|
+
if not _SECURE_DIR_FD:
|
|
111
|
+
raise RuntimeError(_UNSAFE_MUTATION_PLATFORM_ERROR)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _lexical_absolute(path: Path) -> Path:
|
|
115
|
+
"""Return an absolute normalized path without resolving symlinks."""
|
|
116
|
+
return Path(os.path.abspath(os.fspath(path)))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _mutation_parts(path: Path, trusted_root: Path) -> tuple[Path, Path, tuple[str, ...]]:
|
|
120
|
+
normalized_path = _lexical_absolute(path)
|
|
121
|
+
normalized_root = _lexical_absolute(trusted_root)
|
|
122
|
+
try:
|
|
123
|
+
relative = normalized_path.relative_to(normalized_root)
|
|
124
|
+
except ValueError as error:
|
|
125
|
+
raise RuntimeError(
|
|
126
|
+
f"Refusing mutation outside trusted root {normalized_root}: {normalized_path}"
|
|
127
|
+
) from error
|
|
128
|
+
if relative == Path(".") or not relative.parts or ".." in relative.parts:
|
|
129
|
+
raise RuntimeError(
|
|
130
|
+
f"Refusing mutation of trusted root itself: {normalized_path}"
|
|
131
|
+
)
|
|
132
|
+
return normalized_path, normalized_root, relative.parts
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@contextmanager
|
|
136
|
+
def _open_mutation_parent(
|
|
137
|
+
path: Path,
|
|
138
|
+
trusted_root: Path,
|
|
139
|
+
) -> Iterator[tuple[int, Path]]:
|
|
140
|
+
"""Pin each ancestor with ``O_NOFOLLOW`` and yield the target's parent fd."""
|
|
141
|
+
_require_secure_mutation_support()
|
|
142
|
+
normalized_path, normalized_root, parts = _mutation_parts(path, trusted_root)
|
|
143
|
+
directory_fd = -1
|
|
144
|
+
try:
|
|
145
|
+
directory_fd = os.open(normalized_root, _DIRECTORY_FLAGS)
|
|
146
|
+
for part in parts[:-1]:
|
|
147
|
+
next_fd = os.open(part, _DIRECTORY_FLAGS, dir_fd=directory_fd)
|
|
148
|
+
os.close(directory_fd)
|
|
149
|
+
directory_fd = next_fd
|
|
150
|
+
except OSError as error:
|
|
151
|
+
if directory_fd >= 0:
|
|
152
|
+
os.close(directory_fd)
|
|
153
|
+
raise RuntimeError(
|
|
154
|
+
"Refusing mutation through an unsafe ancestor between "
|
|
155
|
+
f"{normalized_root} and {normalized_path.parent}: {error}"
|
|
156
|
+
) from error
|
|
157
|
+
try:
|
|
158
|
+
yield directory_fd, normalized_path
|
|
159
|
+
finally:
|
|
160
|
+
os.close(directory_fd)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _assert_mutation_parent(path: Path, trusted_root: Path) -> None:
|
|
164
|
+
"""Reject any symlink/non-directory ancestor below an explicit boundary."""
|
|
165
|
+
with _open_mutation_parent(path, trusted_root):
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _safe_lstat(path: Path, trusted_root: Path) -> os.stat_result | None:
|
|
170
|
+
with _open_mutation_parent(path, trusted_root) as (parent_fd, normalized_path):
|
|
171
|
+
try:
|
|
172
|
+
return os.stat(
|
|
173
|
+
normalized_path.name,
|
|
174
|
+
dir_fd=parent_fd,
|
|
175
|
+
follow_symlinks=False,
|
|
176
|
+
)
|
|
177
|
+
except FileNotFoundError:
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _safe_readlink(path: Path, trusted_root: Path) -> str:
|
|
182
|
+
with _open_mutation_parent(path, trusted_root) as (parent_fd, normalized_path):
|
|
183
|
+
return os.readlink(normalized_path.name, dir_fd=parent_fd)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _safe_unlink(path: Path, trusted_root: Path) -> None:
|
|
187
|
+
_assert_mutation_parent(path, trusted_root)
|
|
188
|
+
with _open_mutation_parent(path, trusted_root) as (parent_fd, normalized_path):
|
|
189
|
+
os.unlink(normalized_path.name, dir_fd=parent_fd)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _safe_rmdir(path: Path, trusted_root: Path) -> None:
|
|
193
|
+
_assert_mutation_parent(path, trusted_root)
|
|
194
|
+
with _open_mutation_parent(path, trusted_root) as (parent_fd, normalized_path):
|
|
195
|
+
os.rmdir(normalized_path.name, dir_fd=parent_fd)
|
|
27
196
|
|
|
28
|
-
def _is_toolkit_link(link_target: str) -> bool:
|
|
29
|
-
"""Check if a symlink target points into the ai-toolkit app directory."""
|
|
30
|
-
return str(app_dir) in link_target or "/ai-toolkit/app/" in link_target
|
|
31
197
|
|
|
198
|
+
def _safe_mkdir(path: Path, mode: int, trusted_root: Path) -> None:
|
|
199
|
+
_assert_mutation_parent(path, trusted_root)
|
|
200
|
+
with _open_mutation_parent(path, trusted_root) as (parent_fd, normalized_path):
|
|
201
|
+
os.mkdir(normalized_path.name, mode=mode, dir_fd=parent_fd)
|
|
32
202
|
|
|
33
|
-
def _strip_all_toolkit_markers(filepath: Path) -> str | None:
|
|
34
|
-
"""Remove all TOOLKIT marker sections from a file.
|
|
35
203
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
204
|
+
def _safe_symlink(target: str, path: Path, trusted_root: Path) -> None:
|
|
205
|
+
_assert_mutation_parent(path, trusted_root)
|
|
206
|
+
with _open_mutation_parent(path, trusted_root) as (parent_fd, normalized_path):
|
|
207
|
+
os.symlink(target, normalized_path.name, dir_fd=parent_fd)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _safe_chmod(path: Path, mode: int, trusted_root: Path) -> None:
|
|
211
|
+
_assert_mutation_parent(path, trusted_root)
|
|
212
|
+
with _open_mutation_parent(path, trusted_root) as (parent_fd, normalized_path):
|
|
213
|
+
os.chmod(
|
|
214
|
+
normalized_path.name,
|
|
215
|
+
mode,
|
|
216
|
+
dir_fd=parent_fd,
|
|
217
|
+
follow_symlinks=False,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _safe_utime(
|
|
222
|
+
path: Path,
|
|
223
|
+
times: tuple[int, int],
|
|
224
|
+
trusted_root: Path,
|
|
225
|
+
) -> None:
|
|
226
|
+
_assert_mutation_parent(path, trusted_root)
|
|
227
|
+
with _open_mutation_parent(path, trusted_root) as (parent_fd, normalized_path):
|
|
228
|
+
os.utime(
|
|
229
|
+
normalized_path.name,
|
|
230
|
+
ns=times,
|
|
231
|
+
dir_fd=parent_fd,
|
|
232
|
+
follow_symlinks=False,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class _UninstallTransaction:
|
|
237
|
+
"""Snapshot touched surfaces and restore their original bytes on failure."""
|
|
238
|
+
|
|
239
|
+
def __init__(self, specs: list[tuple[Path, bool, Path]]) -> None:
|
|
240
|
+
self._entries: dict[Path, _PathSnapshot] = {}
|
|
241
|
+
for path, recursive, trusted_root in specs:
|
|
242
|
+
self._capture(path, recursive=recursive, trusted_root=trusted_root)
|
|
243
|
+
|
|
244
|
+
def _capture(self, path: Path, *, recursive: bool, trusted_root: Path) -> None:
|
|
245
|
+
if not path.exists() and not path.is_symlink():
|
|
246
|
+
return
|
|
247
|
+
metadata = os.lstat(path)
|
|
248
|
+
mode = stat.S_IMODE(metadata.st_mode)
|
|
249
|
+
common = {
|
|
250
|
+
"mode": mode,
|
|
251
|
+
"atime_ns": metadata.st_atime_ns,
|
|
252
|
+
"mtime_ns": metadata.st_mtime_ns,
|
|
253
|
+
"trusted_root": trusted_root,
|
|
254
|
+
}
|
|
255
|
+
if stat.S_ISLNK(metadata.st_mode):
|
|
256
|
+
self._entries.setdefault(
|
|
257
|
+
path,
|
|
258
|
+
_PathSnapshot("symlink", link_target=os.readlink(path), **common),
|
|
259
|
+
)
|
|
260
|
+
return
|
|
261
|
+
if stat.S_ISREG(metadata.st_mode):
|
|
262
|
+
self._entries.setdefault(
|
|
263
|
+
path,
|
|
264
|
+
_PathSnapshot("file", content=path.read_bytes(), **common),
|
|
265
|
+
)
|
|
266
|
+
return
|
|
267
|
+
if not stat.S_ISDIR(metadata.st_mode):
|
|
268
|
+
raise RuntimeError(f"Unsupported uninstall path type: {path}")
|
|
269
|
+
self._entries.setdefault(path, _PathSnapshot("directory", **common))
|
|
270
|
+
if recursive:
|
|
271
|
+
for child in sorted(path.iterdir()):
|
|
272
|
+
self._capture(child, recursive=True, trusted_root=trusted_root)
|
|
273
|
+
|
|
274
|
+
def rollback(self) -> None:
|
|
275
|
+
errors: list[str] = []
|
|
276
|
+
directories = [
|
|
277
|
+
(path, entry)
|
|
278
|
+
for path, entry in self._entries.items()
|
|
279
|
+
if entry.kind == "directory"
|
|
280
|
+
]
|
|
281
|
+
leaves = [
|
|
282
|
+
(path, entry)
|
|
283
|
+
for path, entry in self._entries.items()
|
|
284
|
+
if entry.kind != "directory"
|
|
285
|
+
]
|
|
286
|
+
for path, entry in sorted(directories, key=lambda item: len(item[0].parts)):
|
|
287
|
+
try:
|
|
288
|
+
self._restore_directory(path, entry)
|
|
289
|
+
except (OSError, RuntimeError) as error:
|
|
290
|
+
errors.append(f"{path}: {error}")
|
|
291
|
+
for path, entry in sorted(leaves, key=lambda item: len(item[0].parts)):
|
|
292
|
+
try:
|
|
293
|
+
self._restore_leaf(path, entry)
|
|
294
|
+
except (OSError, RuntimeError) as error:
|
|
295
|
+
errors.append(f"{path}: {error}")
|
|
296
|
+
for path, entry in sorted(
|
|
297
|
+
directories,
|
|
298
|
+
key=lambda item: len(item[0].parts),
|
|
299
|
+
reverse=True,
|
|
300
|
+
):
|
|
301
|
+
try:
|
|
302
|
+
_safe_chmod(path, entry.mode, entry.trusted_root)
|
|
303
|
+
_safe_utime(
|
|
304
|
+
path,
|
|
305
|
+
(entry.atime_ns, entry.mtime_ns),
|
|
306
|
+
entry.trusted_root,
|
|
307
|
+
)
|
|
308
|
+
except (OSError, RuntimeError) as error:
|
|
309
|
+
errors.append(f"{path}: {error}")
|
|
310
|
+
if errors:
|
|
311
|
+
raise RuntimeError("rollback incomplete: " + "; ".join(errors))
|
|
312
|
+
|
|
313
|
+
@staticmethod
|
|
314
|
+
def _restore_directory(path: Path, entry: _PathSnapshot) -> None:
|
|
315
|
+
metadata = _safe_lstat(path, entry.trusted_root)
|
|
316
|
+
if metadata is not None and stat.S_ISLNK(metadata.st_mode):
|
|
317
|
+
raise RuntimeError("directory path became a symlink")
|
|
318
|
+
if metadata is not None and not stat.S_ISDIR(metadata.st_mode):
|
|
319
|
+
raise RuntimeError("directory path became a non-directory")
|
|
320
|
+
if metadata is None:
|
|
321
|
+
_safe_mkdir(path, entry.mode, entry.trusted_root)
|
|
322
|
+
_safe_chmod(path, entry.mode, entry.trusted_root)
|
|
323
|
+
|
|
324
|
+
@staticmethod
|
|
325
|
+
def _restore_leaf(path: Path, entry: _PathSnapshot) -> None:
|
|
326
|
+
metadata = _safe_lstat(path, entry.trusted_root)
|
|
327
|
+
if entry.kind == "file":
|
|
328
|
+
if metadata is not None and stat.S_ISLNK(metadata.st_mode):
|
|
329
|
+
_safe_unlink(path, entry.trusted_root)
|
|
330
|
+
elif metadata is not None and not stat.S_ISREG(metadata.st_mode):
|
|
331
|
+
raise RuntimeError("file path became a non-file")
|
|
332
|
+
_atomic_write_bytes(
|
|
333
|
+
path,
|
|
334
|
+
entry.content or b"",
|
|
335
|
+
entry.mode,
|
|
336
|
+
entry.trusted_root,
|
|
337
|
+
)
|
|
338
|
+
_safe_utime(
|
|
339
|
+
path,
|
|
340
|
+
(entry.atime_ns, entry.mtime_ns),
|
|
341
|
+
entry.trusted_root,
|
|
342
|
+
)
|
|
343
|
+
return
|
|
344
|
+
if metadata is not None and stat.S_ISLNK(metadata.st_mode):
|
|
345
|
+
if _safe_readlink(path, entry.trusted_root) == entry.link_target:
|
|
346
|
+
return
|
|
347
|
+
_safe_unlink(path, entry.trusted_root)
|
|
348
|
+
elif metadata is not None:
|
|
349
|
+
if stat.S_ISDIR(metadata.st_mode):
|
|
350
|
+
_safe_rmdir(path, entry.trusted_root)
|
|
351
|
+
else:
|
|
352
|
+
_safe_unlink(path, entry.trusted_root)
|
|
353
|
+
_safe_symlink(entry.link_target or "", path, entry.trusted_root)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _link_target(path: Path) -> Path:
|
|
357
|
+
raw = Path(os.readlink(path))
|
|
358
|
+
return (path.parent / raw if not raw.is_absolute() else raw).resolve(strict=False)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _is_relative_to(path: Path, parent: Path) -> bool:
|
|
362
|
+
try:
|
|
363
|
+
path.relative_to(parent)
|
|
364
|
+
return True
|
|
365
|
+
except ValueError:
|
|
366
|
+
return False
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _is_toolkit_link(path: Path) -> bool:
|
|
370
|
+
"""Return whether a symlink points into an ai-toolkit ``app`` directory."""
|
|
371
|
+
if not path.is_symlink():
|
|
372
|
+
return False
|
|
373
|
+
target = _link_target(path)
|
|
374
|
+
if _is_relative_to(target, app_dir.resolve()):
|
|
375
|
+
return True
|
|
376
|
+
normalized = target.as_posix()
|
|
377
|
+
return "/ai-toolkit/app/" in normalized
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _read_prefix(path: Path, limit: int = 512) -> str:
|
|
381
|
+
if path.is_symlink() or not path.is_file():
|
|
382
|
+
return ""
|
|
383
|
+
try:
|
|
384
|
+
return path.read_text(encoding="utf-8")[:limit]
|
|
385
|
+
except (OSError, UnicodeError):
|
|
386
|
+
return ""
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _has_marker(path: Path, marker: str, *, lines: int | None = None) -> bool:
|
|
390
|
+
if path.is_symlink() or not path.is_file():
|
|
391
|
+
return False
|
|
392
|
+
try:
|
|
393
|
+
content = path.read_text(encoding="utf-8")
|
|
394
|
+
except (OSError, UnicodeError):
|
|
395
|
+
return False
|
|
396
|
+
if lines is not None:
|
|
397
|
+
content = "\n".join(content.splitlines()[:lines])
|
|
398
|
+
return marker in content
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _atomic_write_bytes(
|
|
402
|
+
path: Path,
|
|
403
|
+
content: bytes,
|
|
404
|
+
mode: int,
|
|
405
|
+
trusted_root: Path,
|
|
406
|
+
) -> None:
|
|
407
|
+
_assert_mutation_parent(path, trusted_root)
|
|
408
|
+
metadata = _safe_lstat(path, trusted_root)
|
|
409
|
+
if metadata is not None and stat.S_ISLNK(metadata.st_mode):
|
|
410
|
+
raise RuntimeError(f"Refusing to replace symlink: {path}")
|
|
411
|
+
|
|
412
|
+
with _open_mutation_parent(path, trusted_root) as (parent_fd, normalized_path):
|
|
413
|
+
target_name = normalized_path.name
|
|
414
|
+
temporary_name = f".{target_name}.{secrets.token_hex(8)}.tmp"
|
|
415
|
+
temporary_fd = -1
|
|
416
|
+
try:
|
|
417
|
+
temporary_fd = os.open(
|
|
418
|
+
temporary_name,
|
|
419
|
+
os.O_WRONLY
|
|
420
|
+
| os.O_CREAT
|
|
421
|
+
| os.O_EXCL
|
|
422
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
423
|
+
| getattr(os, "O_NOFOLLOW", 0),
|
|
424
|
+
mode,
|
|
425
|
+
dir_fd=parent_fd,
|
|
426
|
+
)
|
|
427
|
+
with os.fdopen(temporary_fd, "wb") as handle:
|
|
428
|
+
temporary_fd = -1
|
|
429
|
+
handle.write(content)
|
|
430
|
+
handle.flush()
|
|
431
|
+
os.fsync(handle.fileno())
|
|
432
|
+
os.fchmod(handle.fileno(), mode)
|
|
433
|
+
current = (
|
|
434
|
+
os.stat(
|
|
435
|
+
target_name,
|
|
436
|
+
dir_fd=parent_fd,
|
|
437
|
+
follow_symlinks=False,
|
|
438
|
+
)
|
|
439
|
+
if metadata is not None
|
|
440
|
+
else None
|
|
441
|
+
)
|
|
442
|
+
if current is not None and stat.S_ISLNK(current.st_mode):
|
|
443
|
+
raise RuntimeError(f"Destination became a symlink: {path}")
|
|
444
|
+
os.replace(
|
|
445
|
+
temporary_name,
|
|
446
|
+
target_name,
|
|
447
|
+
src_dir_fd=parent_fd,
|
|
448
|
+
dst_dir_fd=parent_fd,
|
|
449
|
+
)
|
|
450
|
+
finally:
|
|
451
|
+
if temporary_fd >= 0:
|
|
452
|
+
os.close(temporary_fd)
|
|
453
|
+
try:
|
|
454
|
+
os.unlink(temporary_name, dir_fd=parent_fd)
|
|
455
|
+
except FileNotFoundError:
|
|
456
|
+
pass
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _atomic_write_text(path: Path, content: str, trusted_root: Path) -> None:
|
|
460
|
+
metadata = _safe_lstat(path, trusted_root)
|
|
461
|
+
mode = stat.S_IMODE(metadata.st_mode) if metadata is not None else 0o644
|
|
462
|
+
_atomic_write_bytes(path, content.encode(), mode, trusted_root)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _write_or_remove(path: Path, content: str, trusted_root: Path) -> None:
|
|
466
|
+
content = trim_trailing_blanks(content).lstrip("\n")
|
|
467
|
+
if content.strip():
|
|
468
|
+
_atomic_write_text(path, content + "\n", trusted_root)
|
|
469
|
+
else:
|
|
470
|
+
_safe_unlink(path, trusted_root)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _strip_instruction_file(
|
|
474
|
+
path: Path,
|
|
475
|
+
*,
|
|
476
|
+
preserve_plugins: bool,
|
|
477
|
+
trusted_root: Path,
|
|
478
|
+
) -> bool:
|
|
479
|
+
if path.is_symlink() or not path.is_file():
|
|
480
|
+
return False
|
|
481
|
+
original = path.read_text(encoding="utf-8")
|
|
482
|
+
if "<!-- TOOLKIT:" not in original:
|
|
483
|
+
return False
|
|
484
|
+
if preserve_plugins:
|
|
485
|
+
updated = original
|
|
486
|
+
sections = set(_TOOLKIT_SECTION_RE.findall(original))
|
|
487
|
+
for section in sorted(sections):
|
|
488
|
+
if not section.startswith("plugin-"):
|
|
489
|
+
updated = strip_section(updated, section)
|
|
490
|
+
else:
|
|
491
|
+
updated = strip_all_sections(original)
|
|
492
|
+
if updated == original:
|
|
493
|
+
return False
|
|
494
|
+
_write_or_remove(path, updated, trusted_root)
|
|
495
|
+
return True
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _load_json(path: Path, label: str) -> dict[str, Any] | None:
|
|
499
|
+
if path.is_symlink() or not path.is_file():
|
|
500
|
+
return None
|
|
501
|
+
try:
|
|
502
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
503
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
504
|
+
print(f" Warning: preserved invalid {label}: {path} ({error})", file=sys.stderr)
|
|
505
|
+
return None
|
|
506
|
+
if not isinstance(data, dict):
|
|
507
|
+
print(f" Warning: preserved non-object {label}: {path}", file=sys.stderr)
|
|
39
508
|
return None
|
|
509
|
+
return data
|
|
40
510
|
|
|
41
|
-
content = filepath.read_text(encoding="utf-8")
|
|
42
|
-
if "<!-- TOOLKIT:" not in content:
|
|
43
|
-
return content
|
|
44
511
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
for
|
|
48
|
-
|
|
49
|
-
if re.match(r"^<!-- TOOLKIT:\S+ START -->$", stripped):
|
|
50
|
-
skip = True
|
|
512
|
+
def _prune_empty(*paths: Path, trusted_root: Path) -> None:
|
|
513
|
+
unique = sorted(set(paths), key=lambda path: len(path.parts), reverse=True)
|
|
514
|
+
for path in unique:
|
|
515
|
+
if path.is_symlink() or not path.is_dir():
|
|
51
516
|
continue
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
lines.append(line)
|
|
57
|
-
|
|
58
|
-
result = "".join(lines).strip()
|
|
59
|
-
return result
|
|
517
|
+
try:
|
|
518
|
+
_safe_rmdir(path, trusted_root)
|
|
519
|
+
except (OSError, RuntimeError):
|
|
520
|
+
pass
|
|
60
521
|
|
|
61
522
|
|
|
62
523
|
# ---------------------------------------------------------------------------
|
|
63
|
-
#
|
|
524
|
+
# Claude Code compatibility cleanup
|
|
64
525
|
# ---------------------------------------------------------------------------
|
|
65
526
|
|
|
66
|
-
def
|
|
67
|
-
|
|
527
|
+
def _discover_claude_link_directories(claude_dir: Path) -> list[tuple[str, str]]:
|
|
528
|
+
found: list[tuple[str, str]] = []
|
|
529
|
+
for item in ("agents", "skills", "commands"):
|
|
530
|
+
target = claude_dir / item
|
|
531
|
+
if target.is_symlink() and _is_toolkit_link(target):
|
|
532
|
+
found.append((f"Symlink: {item} -> {target.readlink()} (directory)", "old-dir"))
|
|
533
|
+
return found
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _managed_links(directory: Path, pattern: str | None = None) -> list[Path]:
|
|
537
|
+
if not directory.is_dir() or directory.is_symlink():
|
|
538
|
+
return []
|
|
539
|
+
candidates = directory.glob(pattern) if pattern is not None else directory.iterdir()
|
|
540
|
+
return [
|
|
541
|
+
path for path in candidates
|
|
542
|
+
if path.is_symlink() and _is_toolkit_link(path)
|
|
543
|
+
]
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _discover_claude_hooks(claude_dir: Path) -> list[tuple[str, str]]:
|
|
547
|
+
hooks_file = claude_dir / "hooks.json"
|
|
548
|
+
if hooks_file.is_symlink() and _is_toolkit_link(hooks_file):
|
|
549
|
+
return [(f"Symlink: hooks.json -> {hooks_file.readlink()} (legacy)", "hooks-link")]
|
|
550
|
+
if hooks_file.is_symlink() or not hooks_file.is_file():
|
|
551
|
+
return []
|
|
552
|
+
content = hooks_file.read_text(encoding="utf-8")
|
|
553
|
+
if '"_source"' in content and '"ai-toolkit"' in content:
|
|
554
|
+
return [("Merged: hooks.json (toolkit entries)", "hooks-merged")]
|
|
555
|
+
return []
|
|
68
556
|
|
|
69
|
-
|
|
70
|
-
|
|
557
|
+
|
|
558
|
+
def _discover_claude_markers(claude_dir: Path) -> list[tuple[str, str]]:
|
|
71
559
|
found: list[tuple[str, str]] = []
|
|
560
|
+
for item in ("constitution.md", "ARCHITECTURE.md"):
|
|
561
|
+
target = claude_dir / item
|
|
562
|
+
if target.is_symlink() and _is_toolkit_link(target):
|
|
563
|
+
found.append((f"Symlink: {item} -> {target.readlink()} (legacy)", "marker-link"))
|
|
564
|
+
elif not target.is_symlink() and target.is_file() and "<!-- TOOLKIT:" in (
|
|
565
|
+
target.read_text(encoding="utf-8")
|
|
566
|
+
):
|
|
567
|
+
found.append((f"Injected: {item} (marker-based)", "marker-inject"))
|
|
568
|
+
return found
|
|
72
569
|
|
|
73
|
-
|
|
74
|
-
|
|
570
|
+
|
|
571
|
+
def discover_components(claude_dir: Path) -> list[tuple[str, str]]:
|
|
572
|
+
"""Find verifiably managed Claude Code components."""
|
|
573
|
+
found = _discover_claude_link_directories(claude_dir)
|
|
574
|
+
agent_links = _managed_links(claude_dir / "agents", "*.md")
|
|
575
|
+
if agent_links:
|
|
576
|
+
found.append((f"Symlinks: agents/ ({len(agent_links)} toolkit files)", "agent-link"))
|
|
577
|
+
skill_links = _managed_links(claude_dir / "skills")
|
|
578
|
+
if skill_links:
|
|
579
|
+
found.append((f"Symlinks: skills/ ({len(skill_links)} toolkit directories)", "skill-link"))
|
|
580
|
+
found.extend(_discover_claude_hooks(claude_dir))
|
|
581
|
+
found.extend(_discover_claude_markers(claude_dir))
|
|
582
|
+
return found
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _remove_claude_link_directories(
|
|
586
|
+
claude_dir: Path,
|
|
587
|
+
trusted_root: Path,
|
|
588
|
+
) -> None:
|
|
589
|
+
for item in ("agents", "skills", "commands"):
|
|
75
590
|
target = claude_dir / item
|
|
76
|
-
if target.is_symlink():
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
# Check per-file agent symlinks (new-style)
|
|
81
|
-
agents_dir = claude_dir / "agents"
|
|
82
|
-
if agents_dir.is_dir() and not agents_dir.is_symlink():
|
|
83
|
-
for agent in sorted(agents_dir.glob("*.md")):
|
|
84
|
-
if not agent.is_symlink():
|
|
85
|
-
continue
|
|
86
|
-
link_target = str(agent.readlink())
|
|
87
|
-
if _is_toolkit_link(link_target):
|
|
88
|
-
found.append(
|
|
89
|
-
(f"Symlink: agents/{agent.name} -> {link_target}", "agent-link")
|
|
90
|
-
)
|
|
591
|
+
if target.is_symlink() and _is_toolkit_link(target):
|
|
592
|
+
_safe_unlink(target, trusted_root)
|
|
593
|
+
print(f" Removed: .claude/{item} (managed directory symlink)")
|
|
91
594
|
|
|
92
|
-
# Check per-directory skill symlinks (new-style)
|
|
93
|
-
skills_dir = claude_dir / "skills"
|
|
94
|
-
if skills_dir.is_dir() and not skills_dir.is_symlink():
|
|
95
|
-
for skill in sorted(skills_dir.iterdir()):
|
|
96
|
-
if not skill.is_symlink():
|
|
97
|
-
continue
|
|
98
|
-
link_target = str(skill.readlink())
|
|
99
|
-
if _is_toolkit_link(link_target):
|
|
100
|
-
found.append(
|
|
101
|
-
(f"Symlink: skills/{skill.name}/ -> {link_target}", "skill-link")
|
|
102
|
-
)
|
|
103
595
|
|
|
104
|
-
|
|
596
|
+
def _remove_claude_links(
|
|
597
|
+
directory: Path,
|
|
598
|
+
pattern: str | None,
|
|
599
|
+
label: str,
|
|
600
|
+
trusted_root: Path,
|
|
601
|
+
) -> None:
|
|
602
|
+
managed = _managed_links(directory, pattern)
|
|
603
|
+
for path in managed:
|
|
604
|
+
if not path.is_symlink() or not _is_toolkit_link(path):
|
|
605
|
+
raise RuntimeError(f"Managed Claude link changed before removal: {path}")
|
|
606
|
+
_safe_unlink(path, trusted_root)
|
|
607
|
+
if managed:
|
|
608
|
+
print(f" Removed: {len(managed)} Claude {label} symlink(s)")
|
|
609
|
+
_prune_empty(directory, trusted_root=trusted_root)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _remove_claude_hooks(claude_dir: Path, trusted_root: Path) -> None:
|
|
105
613
|
hooks_file = claude_dir / "hooks.json"
|
|
106
|
-
if hooks_file.is_symlink():
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
elif hooks_file.is_file():
|
|
614
|
+
if hooks_file.is_symlink() and _is_toolkit_link(hooks_file):
|
|
615
|
+
_safe_unlink(hooks_file, trusted_root)
|
|
616
|
+
print(" Removed: .claude/hooks.json (managed legacy symlink)")
|
|
617
|
+
elif not hooks_file.is_symlink() and hooks_file.is_file():
|
|
111
618
|
content = hooks_file.read_text(encoding="utf-8")
|
|
112
619
|
if '"_source"' in content and '"ai-toolkit"' in content:
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
)
|
|
620
|
+
merge_hooks = toolkit_dir / "scripts" / "merge-hooks.py"
|
|
621
|
+
metadata = _safe_lstat(hooks_file, trusted_root)
|
|
622
|
+
mode = stat.S_IMODE(metadata.st_mode) if metadata is not None else 0o644
|
|
623
|
+
with tempfile.TemporaryDirectory(prefix="ai-toolkit-uninstall-") as directory:
|
|
624
|
+
temporary = Path(directory) / "hooks.json"
|
|
625
|
+
temporary.write_text(content, encoding="utf-8")
|
|
626
|
+
subprocess.run(
|
|
627
|
+
["python3", str(merge_hooks), "strip", str(temporary)],
|
|
628
|
+
check=True,
|
|
629
|
+
)
|
|
630
|
+
updated = temporary.read_bytes()
|
|
631
|
+
_atomic_write_bytes(hooks_file, updated, mode, trusted_root)
|
|
632
|
+
print(" Stripped: .claude/hooks.json (user hooks preserved)")
|
|
117
633
|
|
|
118
|
-
|
|
634
|
+
|
|
635
|
+
def _remove_claude_markers(claude_dir: Path, trusted_root: Path) -> None:
|
|
119
636
|
for item in ("constitution.md", "ARCHITECTURE.md"):
|
|
120
637
|
target = claude_dir / item
|
|
121
|
-
if target.is_symlink():
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
638
|
+
if target.is_symlink() and _is_toolkit_link(target):
|
|
639
|
+
_safe_unlink(target, trusted_root)
|
|
640
|
+
print(f" Removed: .claude/{item} (managed legacy symlink)")
|
|
641
|
+
elif _strip_instruction_file(
|
|
642
|
+
target,
|
|
643
|
+
preserve_plugins=False,
|
|
644
|
+
trusted_root=trusted_root,
|
|
645
|
+
):
|
|
646
|
+
print(f" Stripped: .claude/{item} (user content preserved)")
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def remove_components(claude_dir: Path, trusted_root: Path) -> None:
|
|
650
|
+
"""Remove only verifiably managed Claude Code components."""
|
|
651
|
+
_remove_claude_link_directories(claude_dir, trusted_root)
|
|
652
|
+
_remove_claude_links(
|
|
653
|
+
claude_dir / "agents",
|
|
654
|
+
"*.md",
|
|
655
|
+
"agent",
|
|
656
|
+
trusted_root,
|
|
657
|
+
)
|
|
658
|
+
_remove_claude_links(
|
|
659
|
+
claude_dir / "skills",
|
|
660
|
+
None,
|
|
661
|
+
"skill",
|
|
662
|
+
trusted_root,
|
|
663
|
+
)
|
|
664
|
+
_remove_claude_hooks(claude_dir, trusted_root)
|
|
665
|
+
_remove_claude_markers(claude_dir, trusted_root)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
# ---------------------------------------------------------------------------
|
|
669
|
+
# Codex cleanup
|
|
670
|
+
# ---------------------------------------------------------------------------
|
|
671
|
+
|
|
672
|
+
def _is_codex_agent(path: Path) -> bool:
|
|
673
|
+
return _has_marker(path, CODEX_AGENT_MARKER, lines=3)
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _is_codex_skill(path: Path) -> bool:
|
|
677
|
+
if path.is_symlink():
|
|
678
|
+
return _is_toolkit_link(path)
|
|
679
|
+
marker = path / CODEX_ADAPTED_SKILL_MARKER
|
|
680
|
+
return path.is_dir() and not marker.is_symlink() and marker.is_file()
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _remove_codex_skills(skills_root: Path, trusted_root: Path) -> int:
|
|
684
|
+
if not skills_root.is_dir() or skills_root.is_symlink():
|
|
685
|
+
return 0
|
|
686
|
+
removed = 0
|
|
687
|
+
for skill in sorted(skills_root.iterdir()):
|
|
688
|
+
if skill.is_symlink():
|
|
689
|
+
if _is_toolkit_link(skill):
|
|
690
|
+
_safe_unlink(skill, trusted_root)
|
|
691
|
+
removed += 1
|
|
692
|
+
continue
|
|
693
|
+
if not _is_codex_skill(skill):
|
|
694
|
+
continue
|
|
695
|
+
for child in sorted(skill.iterdir()):
|
|
696
|
+
if child.name in {"SKILL.md", CODEX_ADAPTED_SKILL_MARKER}:
|
|
697
|
+
if not child.is_symlink() and child.is_file():
|
|
698
|
+
_safe_unlink(child, trusted_root)
|
|
699
|
+
continue
|
|
700
|
+
if child.is_symlink() and _is_toolkit_link(child):
|
|
701
|
+
_safe_unlink(child, trusted_root)
|
|
702
|
+
_prune_empty(skill, trusted_root=trusted_root)
|
|
703
|
+
removed += 1
|
|
704
|
+
_prune_empty(skills_root, skills_root.parent, trusted_root=trusted_root)
|
|
705
|
+
return removed
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _is_codex_core_handler(handler: Any, group: dict[str, Any]) -> bool:
|
|
709
|
+
if group.get("_source") == "ai-toolkit":
|
|
710
|
+
return True
|
|
711
|
+
if not isinstance(handler, dict):
|
|
712
|
+
return False
|
|
713
|
+
if handler.get("_source") == "ai-toolkit":
|
|
714
|
+
return True
|
|
715
|
+
command = handler.get("command")
|
|
716
|
+
return isinstance(command, str) and (
|
|
717
|
+
_CODEX_OWNER_RE.search(command) is not None
|
|
718
|
+
or LEGACY_CODEX_HOOK_PATH in command
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _without_codex_hooks(data: dict[str, Any]) -> tuple[dict[str, Any], int]:
|
|
723
|
+
updated = copy.deepcopy(data)
|
|
724
|
+
hooks = updated.get("hooks")
|
|
725
|
+
if not isinstance(hooks, dict):
|
|
726
|
+
return updated, 0
|
|
727
|
+
removed = 0
|
|
728
|
+
retained_events: dict[str, Any] = {}
|
|
729
|
+
for event, groups in hooks.items():
|
|
730
|
+
if not isinstance(groups, list):
|
|
731
|
+
retained_events[event] = groups
|
|
732
|
+
continue
|
|
733
|
+
retained_groups: list[Any] = []
|
|
734
|
+
for group in groups:
|
|
735
|
+
if not isinstance(group, dict) or not isinstance(group.get("hooks"), list):
|
|
736
|
+
retained_groups.append(group)
|
|
737
|
+
continue
|
|
738
|
+
handlers = group["hooks"]
|
|
739
|
+
retained = [
|
|
740
|
+
handler for handler in handlers
|
|
741
|
+
if not _is_codex_core_handler(handler, group)
|
|
742
|
+
]
|
|
743
|
+
removed += len(handlers) - len(retained)
|
|
744
|
+
if retained:
|
|
745
|
+
retained_group = dict(group)
|
|
746
|
+
retained_group["hooks"] = retained
|
|
747
|
+
if retained_group.get("_source") == "ai-toolkit":
|
|
748
|
+
retained_group.pop("_source", None)
|
|
749
|
+
retained_groups.append(retained_group)
|
|
750
|
+
if retained_groups:
|
|
751
|
+
retained_events[event] = retained_groups
|
|
752
|
+
updated["hooks"] = retained_events
|
|
753
|
+
return updated, removed
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _remove_codex_hooks(path: Path, trusted_root: Path) -> int:
|
|
757
|
+
data = _load_json(path, "Codex hooks file")
|
|
758
|
+
if data is None:
|
|
759
|
+
return 0
|
|
760
|
+
updated, removed = _without_codex_hooks(data)
|
|
761
|
+
if not removed:
|
|
762
|
+
return 0
|
|
763
|
+
if not updated.get("hooks") and set(updated) == {"hooks"}:
|
|
764
|
+
_safe_unlink(path, trusted_root)
|
|
765
|
+
else:
|
|
766
|
+
_atomic_write_text(
|
|
767
|
+
path,
|
|
768
|
+
json.dumps(updated, indent=4, ensure_ascii=False) + "\n",
|
|
769
|
+
trusted_root,
|
|
135
770
|
)
|
|
771
|
+
return removed
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _remove_marked_assets(root: Path, marker: str, trusted_root: Path) -> int:
|
|
775
|
+
if not root.is_dir() or root.is_symlink():
|
|
776
|
+
return 0
|
|
777
|
+
removed = 0
|
|
778
|
+
for path in sorted(root.iterdir()):
|
|
779
|
+
if path.is_symlink() or not path.is_file():
|
|
780
|
+
continue
|
|
781
|
+
if marker in _read_prefix(path):
|
|
782
|
+
if marker not in _read_prefix(path):
|
|
783
|
+
raise RuntimeError(f"Managed hook asset changed before removal: {path}")
|
|
784
|
+
_safe_unlink(path, trusted_root)
|
|
785
|
+
removed += 1
|
|
786
|
+
_prune_empty(root, trusted_root=trusted_root)
|
|
787
|
+
return removed
|
|
788
|
+
|
|
136
789
|
|
|
790
|
+
def _discover_codex(surface: CodexSurface) -> list[tuple[str, str]]:
|
|
791
|
+
found: list[tuple[str, str]] = []
|
|
792
|
+
if surface.instructions.is_file() and "<!-- TOOLKIT:" in (
|
|
793
|
+
surface.instructions.read_text(encoding="utf-8")
|
|
794
|
+
):
|
|
795
|
+
found.append((f"Injected: {surface.instructions} (Codex instructions)", "codex-rules"))
|
|
796
|
+
agents = surface.config_root / "agents"
|
|
797
|
+
if agents.is_dir() and not agents.is_symlink():
|
|
798
|
+
count = sum(1 for path in agents.glob("*.toml") if _is_codex_agent(path))
|
|
799
|
+
if count:
|
|
800
|
+
found.append((f"Managed: {agents} ({count} Codex agents)", "codex-agents"))
|
|
801
|
+
if surface.skills_root.is_dir() and not surface.skills_root.is_symlink():
|
|
802
|
+
count = sum(1 for path in surface.skills_root.iterdir() if _is_codex_skill(path))
|
|
803
|
+
if count:
|
|
804
|
+
found.append((f"Managed: {surface.skills_root} ({count} Codex skills)", "codex-skills"))
|
|
805
|
+
hooks_path = surface.config_root / "hooks.json"
|
|
806
|
+
data = _load_json(hooks_path, "Codex hooks file")
|
|
807
|
+
if data is not None:
|
|
808
|
+
_, count = _without_codex_hooks(data)
|
|
809
|
+
if count:
|
|
810
|
+
found.append((f"Merged: {hooks_path} ({count} Codex hooks)", "codex-hooks"))
|
|
811
|
+
if surface.assets_root.is_dir() and not surface.assets_root.is_symlink():
|
|
812
|
+
count = sum(
|
|
813
|
+
1 for path in surface.assets_root.iterdir()
|
|
814
|
+
if not path.is_symlink() and path.is_file()
|
|
815
|
+
and CODEX_HOOK_ASSET_MARKER in _read_prefix(path)
|
|
816
|
+
)
|
|
817
|
+
if count:
|
|
818
|
+
found.append((f"Managed: {surface.assets_root} ({count} Codex hook assets)", "codex-assets"))
|
|
137
819
|
return found
|
|
138
820
|
|
|
139
821
|
|
|
822
|
+
def _remove_codex(surface: CodexSurface) -> None:
|
|
823
|
+
config_boundary = surface.config_root.parent
|
|
824
|
+
instruction_boundary = surface.instructions.parent
|
|
825
|
+
skills_boundary = surface.skills_root.parent.parent
|
|
826
|
+
if _strip_instruction_file(
|
|
827
|
+
surface.instructions,
|
|
828
|
+
preserve_plugins=True,
|
|
829
|
+
trusted_root=instruction_boundary,
|
|
830
|
+
):
|
|
831
|
+
print(f" Stripped: {surface.instructions} (plugin/user content preserved)")
|
|
832
|
+
agents = surface.config_root / "agents"
|
|
833
|
+
removed_agents = 0
|
|
834
|
+
if agents.is_dir() and not agents.is_symlink():
|
|
835
|
+
for path in sorted(agents.glob("*.toml")):
|
|
836
|
+
if _is_codex_agent(path):
|
|
837
|
+
if not _is_codex_agent(path):
|
|
838
|
+
raise RuntimeError(f"Managed Codex agent changed before removal: {path}")
|
|
839
|
+
_safe_unlink(path, config_boundary)
|
|
840
|
+
removed_agents += 1
|
|
841
|
+
_prune_empty(agents, trusted_root=config_boundary)
|
|
842
|
+
if removed_agents:
|
|
843
|
+
print(f" Removed: {removed_agents} Codex native agent(s)")
|
|
844
|
+
removed_skills = _remove_codex_skills(
|
|
845
|
+
surface.skills_root,
|
|
846
|
+
skills_boundary,
|
|
847
|
+
)
|
|
848
|
+
if removed_skills:
|
|
849
|
+
print(f" Removed: {removed_skills} managed Codex skill(s)")
|
|
850
|
+
removed_hooks = _remove_codex_hooks(
|
|
851
|
+
surface.config_root / "hooks.json",
|
|
852
|
+
config_boundary,
|
|
853
|
+
)
|
|
854
|
+
if removed_hooks:
|
|
855
|
+
print(f" Removed: {removed_hooks} managed Codex hook handler(s)")
|
|
856
|
+
removed_assets = _remove_marked_assets(
|
|
857
|
+
surface.assets_root,
|
|
858
|
+
CODEX_HOOK_ASSET_MARKER,
|
|
859
|
+
config_boundary,
|
|
860
|
+
)
|
|
861
|
+
if removed_assets:
|
|
862
|
+
print(f" Removed: {removed_assets} managed Codex hook asset(s)")
|
|
863
|
+
_prune_empty(surface.config_root, trusted_root=config_boundary)
|
|
864
|
+
_prune_empty(surface.skills_root.parent, trusted_root=skills_boundary)
|
|
865
|
+
|
|
866
|
+
|
|
140
867
|
# ---------------------------------------------------------------------------
|
|
141
|
-
#
|
|
868
|
+
# GitHub Copilot cleanup
|
|
142
869
|
# ---------------------------------------------------------------------------
|
|
143
870
|
|
|
144
|
-
def
|
|
145
|
-
|
|
871
|
+
def _is_copilot_file(path: Path) -> bool:
|
|
872
|
+
return _has_marker(path, COPILOT_MARKER, lines=12)
|
|
146
873
|
|
|
147
|
-
# Remove old-style directory symlinks (backward compat)
|
|
148
|
-
for item in ("agents", "skills", "commands"):
|
|
149
|
-
target = claude_dir / item
|
|
150
|
-
if target.is_symlink():
|
|
151
|
-
target.unlink()
|
|
152
|
-
print(f" Removed: {item} (directory symlink)")
|
|
153
874
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
875
|
+
def _safe_manifest_paths(skill: Path) -> list[Path] | None:
|
|
876
|
+
manifest = skill / COPILOT_SKILL_MANIFEST
|
|
877
|
+
if manifest.is_symlink() or not manifest.is_file():
|
|
878
|
+
return None
|
|
879
|
+
try:
|
|
880
|
+
data = json.loads(manifest.read_text(encoding="utf-8"))
|
|
881
|
+
except (OSError, json.JSONDecodeError):
|
|
882
|
+
return None
|
|
883
|
+
if not isinstance(data, list) or any(not isinstance(item, str) for item in data):
|
|
884
|
+
return None
|
|
885
|
+
paths: list[Path] = []
|
|
886
|
+
for item in data:
|
|
887
|
+
relative = Path(item)
|
|
888
|
+
if relative.is_absolute() or ".." in relative.parts or not relative.parts:
|
|
889
|
+
return None
|
|
890
|
+
paths.append(skill.joinpath(*relative.parts))
|
|
891
|
+
return paths
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def _has_symlinked_ancestor(path: Path, boundary: Path) -> bool:
|
|
895
|
+
"""Return whether ``path`` escapes through a symlink below ``boundary``."""
|
|
896
|
+
current = path.parent
|
|
897
|
+
while current != boundary:
|
|
898
|
+
if not _is_relative_to(current, boundary) or current.is_symlink():
|
|
899
|
+
return True
|
|
900
|
+
current = current.parent
|
|
901
|
+
return False
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _is_copilot_skill(skill: Path) -> bool:
|
|
905
|
+
return (
|
|
906
|
+
not skill.is_symlink()
|
|
907
|
+
and skill.is_dir()
|
|
908
|
+
and _is_copilot_file(skill / "SKILL.md")
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def _remove_copilot_skill(skill: Path, trusted_root: Path) -> bool:
|
|
913
|
+
if not _is_copilot_skill(skill):
|
|
914
|
+
return False
|
|
915
|
+
manifest = skill / COPILOT_SKILL_MANIFEST
|
|
916
|
+
managed_paths = _safe_manifest_paths(skill)
|
|
917
|
+
removed_parents: set[Path] = set()
|
|
918
|
+
if managed_paths is not None:
|
|
919
|
+
for path in sorted(set(managed_paths), key=lambda item: len(item.parts), reverse=True):
|
|
920
|
+
if (
|
|
921
|
+
path.is_symlink()
|
|
922
|
+
or _has_symlinked_ancestor(path, skill)
|
|
923
|
+
or not path.is_file()
|
|
924
|
+
):
|
|
180
925
|
continue
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
926
|
+
_safe_unlink(path, trusted_root)
|
|
927
|
+
removed_parents.add(path.parent)
|
|
928
|
+
skill_file = skill / "SKILL.md"
|
|
929
|
+
if _is_copilot_file(skill_file):
|
|
930
|
+
if not _is_copilot_file(skill_file):
|
|
931
|
+
raise RuntimeError(f"Managed Copilot skill changed before removal: {skill_file}")
|
|
932
|
+
_safe_unlink(skill_file, trusted_root)
|
|
933
|
+
removed_parents.add(skill)
|
|
934
|
+
if managed_paths is not None and manifest.is_file() and not manifest.is_symlink():
|
|
935
|
+
_safe_unlink(manifest, trusted_root)
|
|
936
|
+
for parent in sorted(removed_parents, key=lambda item: len(item.parts), reverse=True):
|
|
937
|
+
current = parent
|
|
938
|
+
while current != skill.parent and _is_relative_to(current, skill):
|
|
939
|
+
before = current
|
|
940
|
+
_prune_empty(current, trusted_root=trusted_root)
|
|
941
|
+
if before.exists():
|
|
942
|
+
break
|
|
943
|
+
current = current.parent
|
|
944
|
+
_prune_empty(skill, trusted_root=trusted_root)
|
|
945
|
+
return True
|
|
193
946
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
if
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
947
|
+
|
|
948
|
+
def _is_copilot_core_hook(entry: Any) -> bool:
|
|
949
|
+
if not isinstance(entry, dict):
|
|
950
|
+
return False
|
|
951
|
+
env = entry.get("env")
|
|
952
|
+
return isinstance(env, dict) and env.get(HOOK_OWNER_KEY) == "ai-toolkit"
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def _without_copilot_hooks(data: dict[str, Any]) -> tuple[dict[str, Any], int]:
|
|
956
|
+
updated = copy.deepcopy(data)
|
|
957
|
+
hooks = updated.get("hooks")
|
|
958
|
+
if not isinstance(hooks, dict):
|
|
959
|
+
return updated, 0
|
|
960
|
+
removed = 0
|
|
961
|
+
retained_hooks: dict[str, Any] = {}
|
|
962
|
+
for event, entries in hooks.items():
|
|
963
|
+
if not isinstance(entries, list):
|
|
964
|
+
retained_hooks[event] = entries
|
|
965
|
+
continue
|
|
966
|
+
retained = [entry for entry in entries if not _is_copilot_core_hook(entry)]
|
|
967
|
+
removed += len(entries) - len(retained)
|
|
968
|
+
if retained:
|
|
969
|
+
retained_hooks[event] = retained
|
|
970
|
+
updated["hooks"] = retained_hooks
|
|
971
|
+
return updated, removed
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def _remove_copilot_hooks(path: Path, trusted_root: Path) -> int:
|
|
975
|
+
data = _load_json(path, "Copilot hooks file")
|
|
976
|
+
if data is None:
|
|
977
|
+
return 0
|
|
978
|
+
updated, removed = _without_copilot_hooks(data)
|
|
979
|
+
if not removed:
|
|
980
|
+
return 0
|
|
981
|
+
if not updated.get("hooks") and set(updated) <= {"version", "hooks"}:
|
|
982
|
+
_safe_unlink(path, trusted_root)
|
|
983
|
+
else:
|
|
984
|
+
_atomic_write_text(
|
|
985
|
+
path,
|
|
986
|
+
json.dumps(updated, indent=2, ensure_ascii=False) + "\n",
|
|
987
|
+
trusted_root,
|
|
988
|
+
)
|
|
989
|
+
return removed
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def _discover_copilot(surface: CopilotSurface) -> list[tuple[str, str]]:
|
|
993
|
+
root = surface.customization_root
|
|
994
|
+
found: list[tuple[str, str]] = []
|
|
995
|
+
if surface.instructions.is_file() and "<!-- TOOLKIT:" in (
|
|
996
|
+
surface.instructions.read_text(encoding="utf-8")
|
|
997
|
+
):
|
|
998
|
+
found.append((f"Injected: {surface.instructions} (Copilot instructions)", "copilot-rules"))
|
|
999
|
+
for directory_name, suffix, kind in (
|
|
1000
|
+
("instructions", ".instructions.md", "instructions"),
|
|
1001
|
+
("agents", ".agent.md", "agents"),
|
|
1002
|
+
("prompts", ".prompt.md", "prompts"),
|
|
1003
|
+
):
|
|
1004
|
+
directory = root / directory_name
|
|
1005
|
+
if directory.is_dir() and not directory.is_symlink():
|
|
1006
|
+
count = sum(
|
|
1007
|
+
1 for path in directory.glob(f"*{suffix}") if _is_copilot_file(path)
|
|
206
1008
|
)
|
|
207
|
-
if
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
1009
|
+
if count:
|
|
1010
|
+
found.append((f"Managed: {directory} ({count} Copilot {kind})", f"copilot-{kind}"))
|
|
1011
|
+
skills = root / "skills"
|
|
1012
|
+
if skills.is_dir() and not skills.is_symlink():
|
|
1013
|
+
count = sum(1 for skill in skills.iterdir() if _is_copilot_skill(skill))
|
|
1014
|
+
if count:
|
|
1015
|
+
found.append((f"Managed: {skills} ({count} Copilot skills)", "copilot-skills"))
|
|
1016
|
+
hooks_path = root / "hooks" / "ai-toolkit.json"
|
|
1017
|
+
data = _load_json(hooks_path, "Copilot hooks file")
|
|
1018
|
+
if data is not None:
|
|
1019
|
+
_, count = _without_copilot_hooks(data)
|
|
1020
|
+
if count:
|
|
1021
|
+
found.append((f"Managed: {hooks_path} ({count} Copilot hooks)", "copilot-hooks"))
|
|
1022
|
+
assets = root / "hooks" / "ai-toolkit"
|
|
1023
|
+
if assets.is_dir() and not assets.is_symlink():
|
|
1024
|
+
count = sum(
|
|
1025
|
+
1 for path in assets.iterdir()
|
|
1026
|
+
if not path.is_symlink() and path.is_file()
|
|
1027
|
+
and COPILOT_HOOK_ASSET_MARKER in _read_prefix(path)
|
|
1028
|
+
)
|
|
1029
|
+
if count:
|
|
1030
|
+
found.append((f"Managed: {assets} ({count} Copilot hook assets)", "copilot-assets"))
|
|
1031
|
+
return found
|
|
211
1032
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
1033
|
+
|
|
1034
|
+
def _remove_copilot(surface: CopilotSurface) -> None:
|
|
1035
|
+
root = surface.customization_root
|
|
1036
|
+
trusted_root = root.parent
|
|
1037
|
+
if _strip_instruction_file(
|
|
1038
|
+
surface.instructions,
|
|
1039
|
+
preserve_plugins=False,
|
|
1040
|
+
trusted_root=trusted_root,
|
|
1041
|
+
):
|
|
1042
|
+
print(f" Stripped: {surface.instructions} (user content preserved)")
|
|
1043
|
+
for directory_name, suffix, label in (
|
|
1044
|
+
("instructions", ".instructions.md", "instruction"),
|
|
1045
|
+
("agents", ".agent.md", "agent"),
|
|
1046
|
+
("prompts", ".prompt.md", "prompt"),
|
|
1047
|
+
):
|
|
1048
|
+
directory = root / directory_name
|
|
1049
|
+
removed = 0
|
|
1050
|
+
if directory.is_dir() and not directory.is_symlink():
|
|
1051
|
+
for path in sorted(directory.glob(f"*{suffix}")):
|
|
1052
|
+
if _is_copilot_file(path):
|
|
1053
|
+
if not _is_copilot_file(path):
|
|
1054
|
+
raise RuntimeError(f"Managed Copilot file changed before removal: {path}")
|
|
1055
|
+
_safe_unlink(path, trusted_root)
|
|
1056
|
+
removed += 1
|
|
1057
|
+
_prune_empty(directory, trusted_root=trusted_root)
|
|
1058
|
+
if removed:
|
|
1059
|
+
print(f" Removed: {removed} managed Copilot {label}(s)")
|
|
1060
|
+
skills = root / "skills"
|
|
1061
|
+
removed_skills = 0
|
|
1062
|
+
if skills.is_dir() and not skills.is_symlink():
|
|
1063
|
+
for skill in sorted(skills.iterdir()):
|
|
1064
|
+
if _remove_copilot_skill(skill, trusted_root):
|
|
1065
|
+
removed_skills += 1
|
|
1066
|
+
_prune_empty(skills, trusted_root=trusted_root)
|
|
1067
|
+
if removed_skills:
|
|
1068
|
+
print(f" Removed: {removed_skills} managed Copilot skill(s)")
|
|
1069
|
+
hooks_dir = root / "hooks"
|
|
1070
|
+
removed_hooks = _remove_copilot_hooks(
|
|
1071
|
+
hooks_dir / "ai-toolkit.json",
|
|
1072
|
+
trusted_root,
|
|
1073
|
+
)
|
|
1074
|
+
if removed_hooks:
|
|
1075
|
+
print(f" Removed: {removed_hooks} managed Copilot hook(s)")
|
|
1076
|
+
removed_assets = _remove_marked_assets(
|
|
1077
|
+
hooks_dir / "ai-toolkit",
|
|
1078
|
+
COPILOT_HOOK_ASSET_MARKER,
|
|
1079
|
+
trusted_root,
|
|
1080
|
+
)
|
|
1081
|
+
if removed_assets:
|
|
1082
|
+
print(f" Removed: {removed_assets} managed Copilot hook asset(s)")
|
|
1083
|
+
_prune_empty(hooks_dir, root, trusted_root=trusted_root)
|
|
228
1084
|
|
|
229
1085
|
|
|
230
1086
|
# ---------------------------------------------------------------------------
|
|
231
|
-
#
|
|
1087
|
+
# Scope resolution, safety preflight, CLI
|
|
232
1088
|
# ---------------------------------------------------------------------------
|
|
233
1089
|
|
|
234
|
-
def
|
|
235
|
-
|
|
236
|
-
|
|
1090
|
+
def _configured_home(env_name: str, fallback: Path, *, strict_absolute: bool) -> Path:
|
|
1091
|
+
value = os.environ.get(env_name)
|
|
1092
|
+
if not value:
|
|
1093
|
+
return fallback
|
|
1094
|
+
path = Path(value).expanduser()
|
|
1095
|
+
if strict_absolute and not path.is_absolute():
|
|
1096
|
+
raise RuntimeError(f"{env_name} must be an absolute path")
|
|
1097
|
+
return path.absolute()
|
|
237
1098
|
|
|
238
|
-
for arg in sys.argv[1:]:
|
|
239
|
-
if arg in ("--yes", "-y"):
|
|
240
|
-
force = True
|
|
241
|
-
else:
|
|
242
|
-
target_dir = Path(arg)
|
|
243
1099
|
|
|
244
|
-
|
|
1100
|
+
def _surface_roots(
|
|
1101
|
+
target: Path,
|
|
1102
|
+
scope: str,
|
|
1103
|
+
) -> tuple[Path, list[CodexSurface], list[CopilotSurface]]:
|
|
1104
|
+
claude = target / ".claude"
|
|
1105
|
+
codex: list[CodexSurface] = []
|
|
1106
|
+
copilot: list[CopilotSurface] = []
|
|
245
1107
|
|
|
246
|
-
if
|
|
247
|
-
|
|
248
|
-
|
|
1108
|
+
if scope in {"local", "both"}:
|
|
1109
|
+
codex.append(CodexSurface(
|
|
1110
|
+
target / ".codex",
|
|
1111
|
+
target / "AGENTS.md",
|
|
1112
|
+
target / ".agents" / "skills",
|
|
1113
|
+
target / ".codex" / "hooks",
|
|
1114
|
+
))
|
|
1115
|
+
github = target / ".github"
|
|
1116
|
+
copilot.append(CopilotSurface(github, github / "copilot-instructions.md"))
|
|
249
1117
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
1118
|
+
if scope in {"global", "both"}:
|
|
1119
|
+
use_environment = scope == "global"
|
|
1120
|
+
codex_root = (
|
|
1121
|
+
_configured_home("CODEX_HOME", target / ".codex", strict_absolute=True)
|
|
1122
|
+
if use_environment else target / ".codex"
|
|
1123
|
+
)
|
|
1124
|
+
codex.append(CodexSurface(
|
|
1125
|
+
codex_root,
|
|
1126
|
+
codex_root / "AGENTS.md",
|
|
1127
|
+
target / ".agents" / "skills",
|
|
1128
|
+
codex_root / "ai-toolkit-hooks",
|
|
1129
|
+
))
|
|
1130
|
+
copilot_root = (
|
|
1131
|
+
_configured_home("COPILOT_HOME", target / ".copilot", strict_absolute=False)
|
|
1132
|
+
if use_environment else target / ".copilot"
|
|
1133
|
+
)
|
|
1134
|
+
copilot.append(CopilotSurface(
|
|
1135
|
+
copilot_root,
|
|
1136
|
+
copilot_root / "copilot-instructions.md",
|
|
1137
|
+
))
|
|
1138
|
+
|
|
1139
|
+
return claude, list(dict.fromkeys(codex)), list(dict.fromkeys(copilot))
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
def _assert_regular_root(path: Path, label: str) -> None:
|
|
1143
|
+
if path.is_symlink():
|
|
1144
|
+
raise RuntimeError(f"Refusing symlinked {label}: {path}")
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def _preflight(
|
|
1148
|
+
target: Path,
|
|
1149
|
+
claude: Path,
|
|
1150
|
+
codex: list[CodexSurface],
|
|
1151
|
+
copilot: list[CopilotSurface],
|
|
1152
|
+
) -> None:
|
|
1153
|
+
_assert_regular_root(target, "uninstall target")
|
|
1154
|
+
_assert_regular_root(claude, "Claude configuration root")
|
|
1155
|
+
for surface in codex:
|
|
1156
|
+
for path, label in (
|
|
1157
|
+
(surface.config_root, "Codex configuration root"),
|
|
1158
|
+
(surface.instructions, "Codex instruction file"),
|
|
1159
|
+
(surface.config_root / "agents", "Codex agents directory"),
|
|
1160
|
+
(surface.config_root / "hooks.json", "Codex hooks file"),
|
|
1161
|
+
(surface.assets_root, "Codex hook assets directory"),
|
|
1162
|
+
(surface.skills_root.parent, "Codex shared agents directory"),
|
|
1163
|
+
(surface.skills_root, "Codex skills directory"),
|
|
1164
|
+
):
|
|
1165
|
+
_assert_regular_root(path, label)
|
|
1166
|
+
for surface in copilot:
|
|
1167
|
+
root = surface.customization_root
|
|
1168
|
+
for path, label in (
|
|
1169
|
+
(root, "Copilot customization root"),
|
|
1170
|
+
(surface.instructions, "Copilot instruction file"),
|
|
1171
|
+
(root / "instructions", "Copilot instructions directory"),
|
|
1172
|
+
(root / "agents", "Copilot agents directory"),
|
|
1173
|
+
(root / "prompts", "Copilot prompts directory"),
|
|
1174
|
+
(root / "skills", "Copilot skills directory"),
|
|
1175
|
+
(root / "hooks", "Copilot hooks directory"),
|
|
1176
|
+
(root / "hooks" / "ai-toolkit.json", "Copilot hooks file"),
|
|
1177
|
+
(root / "hooks" / "ai-toolkit", "Copilot hook assets directory"),
|
|
1178
|
+
):
|
|
1179
|
+
_assert_regular_root(path, label)
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
def _transaction_specs(
|
|
1183
|
+
claude: Path,
|
|
1184
|
+
codex: list[CodexSurface],
|
|
1185
|
+
copilot: list[CopilotSurface],
|
|
1186
|
+
) -> list[tuple[Path, bool, Path]]:
|
|
1187
|
+
specs: dict[Path, tuple[bool, Path]] = {}
|
|
1188
|
+
|
|
1189
|
+
def add(path: Path, recursive: bool, trusted_root: Path) -> None:
|
|
1190
|
+
existing = specs.get(path)
|
|
1191
|
+
if existing is not None and existing[1] != trusted_root:
|
|
1192
|
+
raise RuntimeError(
|
|
1193
|
+
f"Conflicting trusted roots for transaction path {path}: "
|
|
1194
|
+
f"{existing[1]} and {trusted_root}"
|
|
1195
|
+
)
|
|
1196
|
+
specs[path] = (recursive or (existing[0] if existing else False), trusted_root)
|
|
1197
|
+
|
|
1198
|
+
claude_boundary = claude.parent
|
|
1199
|
+
for path, recursive in (
|
|
1200
|
+
(claude / "agents", True),
|
|
1201
|
+
(claude / "skills", True),
|
|
1202
|
+
(claude / "commands", True),
|
|
1203
|
+
(claude / "hooks.json", False),
|
|
1204
|
+
(claude / "constitution.md", False),
|
|
1205
|
+
(claude / "ARCHITECTURE.md", False),
|
|
1206
|
+
):
|
|
1207
|
+
add(path, recursive, claude_boundary)
|
|
1208
|
+
for surface in codex:
|
|
1209
|
+
config_boundary = surface.config_root.parent
|
|
1210
|
+
skills_boundary = surface.skills_root.parent.parent
|
|
1211
|
+
for path, recursive, trusted_root in (
|
|
1212
|
+
(surface.config_root, False, config_boundary),
|
|
1213
|
+
(surface.instructions, False, surface.instructions.parent),
|
|
1214
|
+
(surface.config_root / "agents", True, config_boundary),
|
|
1215
|
+
(surface.config_root / "hooks.json", False, config_boundary),
|
|
1216
|
+
(surface.assets_root, True, config_boundary),
|
|
1217
|
+
(surface.skills_root.parent, False, skills_boundary),
|
|
1218
|
+
(surface.skills_root, True, skills_boundary),
|
|
1219
|
+
):
|
|
1220
|
+
add(path, recursive, trusted_root)
|
|
1221
|
+
for surface in copilot:
|
|
1222
|
+
root = surface.customization_root
|
|
1223
|
+
trusted_root = root.parent
|
|
1224
|
+
for path, recursive in (
|
|
1225
|
+
(root, False),
|
|
1226
|
+
(surface.instructions, False),
|
|
1227
|
+
(root / "instructions", True),
|
|
1228
|
+
(root / "agents", True),
|
|
1229
|
+
(root / "prompts", True),
|
|
1230
|
+
(root / "skills", True),
|
|
1231
|
+
(root / "hooks", True),
|
|
1232
|
+
):
|
|
1233
|
+
add(path, recursive, trusted_root)
|
|
1234
|
+
return [
|
|
1235
|
+
(path, recursive, trusted_root)
|
|
1236
|
+
for path, (recursive, trusted_root) in specs.items()
|
|
1237
|
+
]
|
|
254
1238
|
|
|
255
|
-
# -- Count components to remove ---
|
|
256
|
-
components = discover_components(claude_dir)
|
|
257
1239
|
|
|
258
|
-
|
|
259
|
-
|
|
1240
|
+
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
|
1241
|
+
parser = argparse.ArgumentParser(
|
|
1242
|
+
description=(
|
|
1243
|
+
"Remove only ai-toolkit-managed Claude, Codex, and Copilot "
|
|
1244
|
+
"customizations while preserving user-owned content."
|
|
1245
|
+
),
|
|
1246
|
+
epilog=(
|
|
1247
|
+
"Global Codex and Copilot locations honor CODEX_HOME and "
|
|
1248
|
+
"COPILOT_HOME. A legacy positional target scans local and "
|
|
1249
|
+
"home-style paths below that directory."
|
|
1250
|
+
),
|
|
1251
|
+
)
|
|
1252
|
+
parser.add_argument("legacy_target", nargs="?", type=Path, metavar="target-dir")
|
|
1253
|
+
parser.add_argument("--target", type=Path, help="explicit home or project root")
|
|
1254
|
+
scope = parser.add_mutually_exclusive_group()
|
|
1255
|
+
scope.add_argument("--local", action="store_true", help="remove project-local surfaces")
|
|
1256
|
+
scope.add_argument("--global", dest="global_scope", action="store_true", help="remove user-level surfaces")
|
|
1257
|
+
parser.add_argument("--yes", "-y", action="store_true", help="skip confirmation")
|
|
1258
|
+
args = parser.parse_args(argv)
|
|
1259
|
+
if args.target is not None and args.legacy_target is not None:
|
|
1260
|
+
parser.error("use either --target DIR or the positional target-dir, not both")
|
|
1261
|
+
return args
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
def main(argv: list[str] | None = None) -> None:
|
|
1265
|
+
args = _parse_args(sys.argv[1:] if argv is None else argv)
|
|
1266
|
+
explicit_target = args.target or args.legacy_target
|
|
1267
|
+
if args.local:
|
|
1268
|
+
scope = "local"
|
|
1269
|
+
target = explicit_target or Path.cwd()
|
|
1270
|
+
elif args.global_scope:
|
|
1271
|
+
scope = "global"
|
|
1272
|
+
target = explicit_target or Path.home()
|
|
1273
|
+
elif explicit_target is not None:
|
|
1274
|
+
scope = "both"
|
|
1275
|
+
target = explicit_target
|
|
1276
|
+
else:
|
|
1277
|
+
scope = "global"
|
|
1278
|
+
target = Path.home()
|
|
1279
|
+
target = target.expanduser().absolute()
|
|
1280
|
+
|
|
1281
|
+
if not target.is_dir():
|
|
1282
|
+
print(f"Error: uninstall target is not a directory: {target}", file=sys.stderr)
|
|
1283
|
+
raise SystemExit(1)
|
|
1284
|
+
|
|
1285
|
+
try:
|
|
1286
|
+
claude, codex, copilot = _surface_roots(target, scope)
|
|
1287
|
+
_preflight(target, claude, codex, copilot)
|
|
1288
|
+
components = discover_components(claude)
|
|
1289
|
+
for surface in codex:
|
|
1290
|
+
components.extend(_discover_codex(surface))
|
|
1291
|
+
for surface in copilot:
|
|
1292
|
+
components.extend(_discover_copilot(surface))
|
|
1293
|
+
except (OSError, RuntimeError, UnicodeError) as error:
|
|
1294
|
+
print(f"Error: {error}", file=sys.stderr)
|
|
1295
|
+
raise SystemExit(1) from error
|
|
1296
|
+
|
|
1297
|
+
print("AI Toolkit Uninstaller")
|
|
1298
|
+
print("======================")
|
|
1299
|
+
print(f"Target: {target} ({scope})")
|
|
1300
|
+
print()
|
|
1301
|
+
for description, _ in components:
|
|
1302
|
+
print(f" {description}")
|
|
260
1303
|
|
|
261
1304
|
if not components:
|
|
262
1305
|
print("No toolkit components found. Nothing to remove.")
|
|
263
|
-
|
|
1306
|
+
return
|
|
264
1307
|
|
|
265
1308
|
print()
|
|
266
|
-
print(f"Found {len(components)}
|
|
267
|
-
print("
|
|
1309
|
+
print(f"Found {len(components)} managed component group(s).")
|
|
1310
|
+
print("User-owned files, handlers, skills, and plugin-owned Codex hooks are preserved.")
|
|
268
1311
|
print()
|
|
269
|
-
|
|
270
|
-
# -- Confirm ---
|
|
271
|
-
if not force:
|
|
1312
|
+
if not args.yes:
|
|
272
1313
|
try:
|
|
273
1314
|
response = input("Remove these components? [y/N] ").strip().lower()
|
|
274
1315
|
except (EOFError, KeyboardInterrupt):
|
|
275
1316
|
print("\nCancelled.")
|
|
276
|
-
|
|
277
|
-
if response not in
|
|
1317
|
+
return
|
|
1318
|
+
if response not in {"y", "yes"}:
|
|
278
1319
|
print("Cancelled.")
|
|
279
|
-
|
|
1320
|
+
return
|
|
280
1321
|
|
|
281
|
-
|
|
282
|
-
|
|
1322
|
+
try:
|
|
1323
|
+
_require_secure_mutation_support()
|
|
1324
|
+
except RuntimeError as error:
|
|
1325
|
+
print(f"Error: {error}", file=sys.stderr)
|
|
1326
|
+
raise SystemExit(1) from error
|
|
1327
|
+
|
|
1328
|
+
transaction: _UninstallTransaction | None = None
|
|
1329
|
+
try:
|
|
1330
|
+
transaction = _UninstallTransaction(
|
|
1331
|
+
_transaction_specs(claude, codex, copilot)
|
|
1332
|
+
)
|
|
1333
|
+
_preflight(target, claude, codex, copilot)
|
|
1334
|
+
remove_components(claude, target)
|
|
1335
|
+
for surface in codex:
|
|
1336
|
+
_preflight(target, claude, [surface], [])
|
|
1337
|
+
_remove_codex(surface)
|
|
1338
|
+
for surface in copilot:
|
|
1339
|
+
_preflight(target, claude, [], [surface])
|
|
1340
|
+
_remove_copilot(surface)
|
|
1341
|
+
except (OSError, RuntimeError, subprocess.CalledProcessError) as error:
|
|
1342
|
+
rollback_error: RuntimeError | None = None
|
|
1343
|
+
if transaction is not None:
|
|
1344
|
+
try:
|
|
1345
|
+
transaction.rollback()
|
|
1346
|
+
except RuntimeError as failure:
|
|
1347
|
+
rollback_error = failure
|
|
1348
|
+
if rollback_error is not None:
|
|
1349
|
+
print(
|
|
1350
|
+
f"Error: uninstall stopped: {error}; {rollback_error}",
|
|
1351
|
+
file=sys.stderr,
|
|
1352
|
+
)
|
|
1353
|
+
else:
|
|
1354
|
+
print(f"Error: uninstall stopped and rolled back: {error}", file=sys.stderr)
|
|
1355
|
+
raise SystemExit(1) from error
|
|
283
1356
|
|
|
284
1357
|
print()
|
|
285
|
-
print("
|
|
286
|
-
print(f"{Path.home()}/.claude/CLAUDE.md preserved (contains your global rules).")
|
|
287
|
-
print()
|
|
1358
|
+
print("Managed toolkit components removed successfully.")
|
|
288
1359
|
print("To reinstall: npm install -g @softspark/ai-toolkit && ai-toolkit install")
|
|
289
1360
|
|
|
290
1361
|
|