@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +11 -10
  3. package/app/.claude-plugin/plugin.json +1 -1
  4. package/app/CLAUDE.md.template +3 -0
  5. package/app/agents/fact-checker.md +1 -1
  6. package/app/hooks/_search-capability.sh +3 -2
  7. package/app/hooks/stop-search-check.sh +2 -1
  8. package/benchmarks/ecosystem-doctor-snapshot.json +73 -31
  9. package/kb/procedures/maintenance-sop.md +26 -13
  10. package/kb/procedures/release-verification-sop.md +41 -36
  11. package/kb/reference/architecture-overview.md +23 -7
  12. package/kb/reference/codex-cli-compatibility.md +96 -36
  13. package/kb/reference/extension-api.md +52 -9
  14. package/kb/reference/global-install-model.md +53 -21
  15. package/kb/reference/hooks-catalog.md +44 -8
  16. package/kb/reference/mcp-editor-compatibility.md +27 -6
  17. package/kb/reference/mcp-templates.md +12 -6
  18. package/kb/reference/opencode-compatibility.md +13 -7
  19. package/kb/reference/plugin-pack-conventions.md +7 -7
  20. package/kb/reference/skills-catalog.md +3 -3
  21. package/kb/reference/supported-tools-registry.md +19 -17
  22. package/kb/reference/windows-support.md +26 -3
  23. package/llms-full.txt +443 -180
  24. package/llms.txt +1 -1
  25. package/manifest.json +1 -1
  26. package/package.json +2 -2
  27. package/scripts/codex_skill_adapter.py +448 -198
  28. package/scripts/dir_rules_shared.py +2 -11
  29. package/scripts/ecosystem_tools.json +29 -8
  30. package/scripts/emission.py +5 -91
  31. package/scripts/generate_agents_md.py +4 -87
  32. package/scripts/generate_codex.py +5 -95
  33. package/scripts/generate_codex_agents.py +242 -0
  34. package/scripts/generate_codex_hooks.py +648 -55
  35. package/scripts/generate_codex_skills.py +15 -6
  36. package/scripts/generate_copilot.py +771 -74
  37. package/scripts/generate_copilot_hooks.py +606 -0
  38. package/scripts/generate_cursor_hooks.py +453 -121
  39. package/scripts/generate_opencode_commands.py +4 -6
  40. package/scripts/inject_hook_cli.py +770 -205
  41. package/scripts/injection.py +102 -23
  42. package/scripts/install_steps/ai_tools.py +123 -83
  43. package/scripts/instruction_core.py +95 -0
  44. package/scripts/mcp_editors.py +934 -80
  45. package/scripts/mcp_manager.py +46 -26
  46. package/scripts/plugin.py +291 -114
  47. package/scripts/secure_fs.py +538 -0
  48. package/scripts/uninstall.py +1279 -208
@@ -1,290 +1,1361 @@
1
1
  #!/usr/bin/env python3
2
- """AI Toolkit Uninstaller.
2
+ """Safely remove ai-toolkit-managed runtime customizations.
3
3
 
4
- Removes toolkit symlinks from ~/.claude/ (global install).
5
-
6
- Handles both old-style (whole-directory symlinks) and new-style
7
- (per-file symlinks inside agents/ and skills/ directories).
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-dir]
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 toolkit_dir, app_dir
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
- # Helpers
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
- Returns the remaining content, or None if file doesn't exist.
37
- """
38
- if not filepath.is_file():
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
- lines: list[str] = []
46
- skip = False
47
- for line in content.splitlines(keepends=True):
48
- stripped = line.rstrip("\n")
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
- if re.match(r"^<!-- TOOLKIT:\S+ END -->$", stripped):
53
- skip = False
54
- continue
55
- if not skip:
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
- # Discovery: count what would be removed
524
+ # Claude Code compatibility cleanup
64
525
  # ---------------------------------------------------------------------------
65
526
 
66
- def discover_components(claude_dir: Path) -> list[tuple[str, str]]:
67
- """Find all toolkit components installed in claude_dir.
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
- Returns a list of (description, type) tuples.
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
- # Check old-style directory symlinks (backward compat)
74
- for item in ("agents", "skills"):
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
- link_target = str(target.resolve())
78
- found.append((f"Symlink: {item} -> {link_target} (directory)", "old-dir"))
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
- # Check hooks.json for toolkit entries (merged, not symlinked)
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
- found.append(
108
- (f"Symlink: hooks.json -> {hooks_file.readlink()} (legacy)", "hooks-link")
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
- count = content.count('"ai-toolkit"')
114
- found.append(
115
- (f"Merged: hooks.json ({count} toolkit entries)", "hooks-merged")
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
- # Check marker-injected files (constitution.md, ARCHITECTURE.md)
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
- found.append(
123
- (f"Symlink: {item} -> {target.readlink()} (legacy)", "marker-link")
124
- )
125
- elif target.is_file():
126
- content = target.read_text(encoding="utf-8")
127
- if "<!-- TOOLKIT:" in content:
128
- found.append((f"Injected: {item} (marker-based)", "marker-inject"))
129
-
130
- # Check legacy commands symlink
131
- commands = claude_dir / "commands"
132
- if commands.is_symlink():
133
- found.append(
134
- (f"Symlink: commands -> {commands.readlink()} (legacy)", "old-dir")
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
- # Removal
868
+ # GitHub Copilot cleanup
142
869
  # ---------------------------------------------------------------------------
143
870
 
144
- def remove_components(claude_dir: Path) -> None:
145
- """Remove all toolkit components from claude_dir."""
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
- # Remove per-file agent symlinks (only those pointing into toolkit)
155
- agents_dir = claude_dir / "agents"
156
- if agents_dir.is_dir() and not agents_dir.is_symlink():
157
- removed = 0
158
- for agent in sorted(agents_dir.glob("*.md")):
159
- if not agent.is_symlink():
160
- continue
161
- link_target = str(agent.readlink())
162
- if _is_toolkit_link(link_target):
163
- agent.unlink()
164
- removed += 1
165
- if removed > 0:
166
- print(f" Removed: {removed} agent symlink(s)")
167
- # Remove the agents dir if it is now empty
168
- try:
169
- agents_dir.rmdir()
170
- print(" Removed: agents/ (empty)")
171
- except OSError:
172
- pass # Not empty -- user has custom agents
173
-
174
- # Remove per-directory skill symlinks (only those pointing into toolkit)
175
- skills_dir = claude_dir / "skills"
176
- if skills_dir.is_dir() and not skills_dir.is_symlink():
177
- removed = 0
178
- for skill in sorted(skills_dir.iterdir()):
179
- if not skill.is_symlink():
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
- link_target = str(skill.readlink())
182
- if _is_toolkit_link(link_target):
183
- skill.unlink()
184
- removed += 1
185
- if removed > 0:
186
- print(f" Removed: {removed} skill symlink(s)")
187
- # Remove the skills dir if it is now empty
188
- try:
189
- skills_dir.rmdir()
190
- print(" Removed: skills/ (empty)")
191
- except OSError:
192
- pass # Not empty -- user has custom skills
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
- # Remove toolkit hooks from hooks.json (or remove legacy symlink)
195
- hooks_file = claude_dir / "hooks.json"
196
- if hooks_file.is_symlink():
197
- hooks_file.unlink()
198
- print(" Removed: hooks.json (legacy symlink)")
199
- elif hooks_file.is_file():
200
- content = hooks_file.read_text(encoding="utf-8")
201
- if '"_source"' in content and '"ai-toolkit"' in content:
202
- merge_hooks = toolkit_dir / "scripts" / "merge-hooks.py"
203
- subprocess.run(
204
- ["python3", str(merge_hooks), "strip", str(hooks_file)],
205
- check=True,
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 hooks_file.is_file():
208
- print(" Stripped: hooks.json (toolkit entries removed, user hooks preserved)")
209
- else:
210
- print(" Removed: hooks.json (no user hooks remaining)")
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
- # Remove marker-injected content from constitution.md, ARCHITECTURE.md
213
- for item in ("constitution.md", "ARCHITECTURE.md"):
214
- target = claude_dir / item
215
- if target.is_symlink():
216
- target.unlink()
217
- print(f" Removed: {item} (legacy symlink)")
218
- elif target.is_file():
219
- content = target.read_text(encoding="utf-8")
220
- if "<!-- TOOLKIT:" in content:
221
- remaining = _strip_all_toolkit_markers(target)
222
- if remaining and remaining.strip():
223
- target.write_text(remaining + "\n", encoding="utf-8")
224
- print(f" Stripped: {item} (toolkit content removed, user content preserved)")
225
- else:
226
- target.unlink()
227
- print(f" Removed: {item} (no user content remaining)")
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
- # Main
1087
+ # Scope resolution, safety preflight, CLI
232
1088
  # ---------------------------------------------------------------------------
233
1089
 
234
- def main() -> None:
235
- force = False
236
- target_dir = Path.home()
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
- claude_dir = target_dir / ".claude"
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 not claude_dir.is_dir():
247
- print(f"Error: No .claude/ directory found in {target_dir}")
248
- sys.exit(1)
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
- print("AI Toolkit Uninstaller")
251
- print("==========================")
252
- print(f"Target: {claude_dir}")
253
- print()
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
- for desc, _ in components:
259
- print(f" {desc}")
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
- sys.exit(0)
1306
+ return
264
1307
 
265
1308
  print()
266
- print(f"Found {len(components)} toolkit component(s).")
267
- print("Note: ~/.claude/CLAUDE.md and settings.local.json are NOT removed.")
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
- sys.exit(0)
277
- if response not in ("y", "yes"):
1317
+ return
1318
+ if response not in {"y", "yes"}:
278
1319
  print("Cancelled.")
279
- sys.exit(0)
1320
+ return
280
1321
 
281
- # -- Remove ---
282
- remove_components(claude_dir)
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("Toolkit components removed from ~/.claude/ successfully.")
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