@softspark/ai-toolkit 4.29.2 → 4.30.2

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 (42) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +44 -18
  3. package/app/.claude-plugin/plugin.json +1 -1
  4. package/app/ARCHITECTURE.md +2 -2
  5. package/app/mcp-templates/README.md +7 -2
  6. package/app/mcp-templates/rag-mcp-legal.json +11 -0
  7. package/app/mcp-templates/rag-mcp.json +11 -0
  8. package/app/surface.json +1 -0
  9. package/benchmarks/ecosystem-doctor-snapshot.json +29 -17
  10. package/bin/ai-toolkit.js +8 -0
  11. package/kb/history/completed/dsh-integration-plan-superseded.md +322 -0
  12. package/kb/history/completed/dsh-native-install-target-plan.md +331 -0
  13. package/kb/procedures/ecosystem-sync-sop.md +7 -5
  14. package/kb/procedures/maintenance-sop.md +1 -1
  15. package/kb/procedures/release-verification-sop.md +35 -5
  16. package/kb/reference/architecture-overview.md +24 -5
  17. package/kb/reference/cli-reference.md +1 -1
  18. package/kb/reference/dsh-compatibility.md +183 -0
  19. package/kb/reference/manifest-install.md +112 -5
  20. package/kb/reference/mcp-templates.md +11 -4
  21. package/kb/reference/plugin-pack-conventions.md +35 -18
  22. package/kb/reference/supported-tools-registry.md +30 -6
  23. package/llms-full.txt +1110 -50
  24. package/llms.txt +3 -0
  25. package/manifest.json +2 -2
  26. package/package.json +2 -2
  27. package/scripts/codex_skill_adapter.py +673 -34
  28. package/scripts/config_resolver.py +80 -14
  29. package/scripts/doctor.py +98 -20
  30. package/scripts/ecosystem_tools.json +51 -1
  31. package/scripts/generate_codex_skills.py +22 -20
  32. package/scripts/install.py +30 -13
  33. package/scripts/install_steps/ai_tools.py +97 -33
  34. package/scripts/install_steps/dsh.py +5063 -0
  35. package/scripts/install_steps/install_state.py +1645 -57
  36. package/scripts/mcp_editors.py +5 -2
  37. package/scripts/plugin.py +2495 -163
  38. package/scripts/plugin_mcp.py +279 -0
  39. package/scripts/plugin_rules.py +389 -0
  40. package/scripts/plugin_schema.py +139 -23
  41. package/scripts/uninstall.py +47 -4
  42. package/scripts/validate.py +421 -0
package/scripts/plugin.py CHANGED
@@ -24,18 +24,33 @@ Actions:
24
24
  list Show available plugin packs with install status by runtime
25
25
  status Show installed packs with runtime-specific details
26
26
  """
27
+
27
28
  from __future__ import annotations
28
29
 
29
30
  import json
31
+ import hashlib
30
32
  import os
31
33
  import re
34
+ import secrets
35
+ import signal
32
36
  import shutil
33
37
  import sqlite3 as sqlite
38
+ import stat
34
39
  import subprocess
35
40
  import sys
36
41
  import tempfile
42
+ import threading
43
+ import time
44
+ from contextlib import contextmanager
45
+ from dataclasses import dataclass, field
37
46
  from pathlib import Path
38
47
 
48
+ try:
49
+ import fcntl
50
+ except ImportError: # pragma: no cover - Windows fallback
51
+ fcntl = None
52
+ import msvcrt
53
+
39
54
  sys.path.insert(0, str(Path(__file__).resolve().parent))
40
55
  from _common import app_dir, inject_section, remove_rule_section
41
56
  from injection import strip_section, trim_trailing_blanks
@@ -56,6 +71,20 @@ from generate_codex_hooks import (
56
71
  from install_steps.ai_tools import inject_with_rules
57
72
  from paths import HOOKS_DIR as _HOOKS_DIR
58
73
  from paths import RULES_DIR, TOOLKIT_DATA_DIR
74
+ from mcp_editors import ConfigUpdate, apply_config_updates
75
+ from plugin_mcp import (
76
+ PluginMcpInstallPlan,
77
+ PluginMcpRemovalPlan,
78
+ apply_plugin_mcp_install,
79
+ apply_plugin_mcp_removal,
80
+ prepare_plugin_mcp_install,
81
+ prepare_plugin_mcp_removal,
82
+ )
83
+ from plugin_rules import (
84
+ PluginRulePlan,
85
+ prepare_plugin_rule_install,
86
+ prepare_plugin_rule_removal,
87
+ )
59
88
  from plugin_schema import resolve_hook_event
60
89
 
61
90
 
@@ -73,8 +102,21 @@ CODEX_HOOKS_DIR = CODEX_HOME / "ai-toolkit-hooks"
73
102
  HOOKS_DIR = _HOOKS_DIR
74
103
  PLUGINS_STATE_FILE = TOOLKIT_DATA_DIR / "plugins.json"
75
104
  MEMORY_DB = TOOLKIT_DATA_DIR / "memory.db"
105
+ PLUGIN_LIFECYCLE_LOCK = TOOLKIT_DATA_DIR / "plugin-lifecycle.lock"
106
+ PLUGIN_INIT_LOCK = TOOLKIT_DATA_DIR / "plugin-init.lock"
107
+ PLUGIN_INIT_TIMEOUT_SECONDS = 30
108
+ PLUGIN_INIT_TERMINATE_GRACE_SECONDS = 1.0
109
+ PLUGIN_INIT_KILL_GRACE_SECONDS = 1.0
110
+ _PLUGIN_THREAD_LOCK = threading.RLock()
111
+ _PLUGIN_LOCK_DEPTH = 0
76
112
 
77
113
  VALID_EDITORS = ("claude", "codex", "cursor", "gemini")
114
+ PLUGIN_EDITOR_LABELS = {
115
+ "claude": "Claude",
116
+ "codex": "Codex",
117
+ "cursor": "Cursor",
118
+ "gemini": "Gemini",
119
+ }
78
120
 
79
121
  # Runtimes whose hook config is a JSON document we merge a single pack entry
80
122
  # into, rather than a surface with its own installer. Everything below is read
@@ -95,13 +137,13 @@ JSON_HOOK_RUNTIMES: dict[str, dict] = {
95
137
  # Claude event name -> this runtime's event name. An event with no
96
138
  # mapping is skipped loudly rather than guessed at.
97
139
  "events": {"PreToolUse": "beforeShellExecution"},
98
- "shape": "flat", # entry is the command record itself
140
+ "shape": "flat", # entry is the command record itself
99
141
  "timeout": 10,
100
142
  },
101
143
  "gemini": {
102
144
  "config": Path.home() / ".gemini" / "settings.json",
103
145
  "events": {"PreToolUse": "BeforeTool", "PostToolUse": "AfterTool"},
104
- "shape": "nested", # entry wraps a hooks[] list, optional matcher
146
+ "shape": "nested", # entry wraps a hooks[] list, optional matcher
105
147
  "matcher": "run_shell_command",
106
148
  "root_key": "hooks", # hooks live under a key inside a larger document
107
149
  },
@@ -114,23 +156,77 @@ CODEX_PLUGIN_ASSET_MARKER = "# ai-toolkit-managed: codex-plugin-hook"
114
156
  # State management
115
157
  # ---------------------------------------------------------------------------
116
158
 
159
+
117
160
  def _empty_state() -> dict:
118
161
  # Built from VALID_EDITORS so adding a runtime cannot leave load_state()
119
162
  # indexing a key that was never created.
120
- return {"targets": {editor: {"installed": [], "versions": {}} for editor in VALID_EDITORS}}
163
+ return {
164
+ "shared_asset_ownership": {},
165
+ "targets": {
166
+ editor: {
167
+ "installed": [],
168
+ "versions": {},
169
+ "mcp_ownership": {},
170
+ "rule_ownership": {},
171
+ }
172
+ for editor in VALID_EDITORS
173
+ },
174
+ }
175
+
176
+
177
+ def _normalize_shared_asset_consumers(state: dict) -> None:
178
+ """Migrate legacy shared ownership to explicit per-editor consumers."""
179
+ ownerships = state.get("shared_asset_ownership", {})
180
+ if not isinstance(ownerships, dict):
181
+ return
182
+ for name, ownership in ownerships.items():
183
+ if not isinstance(ownership, dict):
184
+ continue
185
+ entries = ownership.get("entries", {})
186
+ if not isinstance(entries, dict):
187
+ entries = {}
188
+ raw_consumers = ownership.get("consumers")
189
+ consumers: dict[str, list[str]] = {}
190
+ if isinstance(raw_consumers, dict):
191
+ for editor, keys in raw_consumers.items():
192
+ if editor in VALID_EDITORS and isinstance(keys, list):
193
+ consumers[editor] = sorted(
194
+ key
195
+ for key in set(keys)
196
+ if isinstance(key, str) and key in entries
197
+ )
198
+ if not consumers and entries:
199
+ for editor in VALID_EDITORS:
200
+ installed = (
201
+ state.get("targets", {}).get(editor, {}).get("installed", [])
202
+ )
203
+ if name in installed:
204
+ consumers[editor] = sorted(entries)
205
+ ownership["consumers"] = consumers
121
206
 
122
207
 
123
208
  def load_state() -> dict:
124
209
  """Load installed plugins state with backwards compatibility."""
125
210
  state = _empty_state()
126
- if PLUGINS_STATE_FILE.is_file():
211
+ if PLUGINS_STATE_FILE.exists() or PLUGINS_STATE_FILE.is_symlink():
127
212
  try:
128
- with open(PLUGINS_STATE_FILE, encoding="utf-8") as f:
129
- raw = json.load(f)
130
- except (json.JSONDecodeError, OSError):
213
+ version = _read_file_version(PLUGINS_STATE_FILE)
214
+ raw = (
215
+ json.loads(version.content.decode("utf-8"))
216
+ if version is not None
217
+ else {}
218
+ )
219
+ except (UnicodeDecodeError, json.JSONDecodeError):
131
220
  raw = {}
132
221
 
133
222
  if isinstance(raw, dict):
223
+ shared_asset_ownership = raw.get("shared_asset_ownership", {})
224
+ if isinstance(shared_asset_ownership, dict):
225
+ state["shared_asset_ownership"] = {
226
+ name: ownership
227
+ for name, ownership in shared_asset_ownership.items()
228
+ if isinstance(name, str) and isinstance(ownership, dict)
229
+ }
134
230
  if isinstance(raw.get("installed"), list):
135
231
  # Legacy format: Claude-only installs.
136
232
  state["targets"]["claude"]["installed"] = sorted(set(raw["installed"]))
@@ -148,15 +244,38 @@ def load_state() -> dict:
148
244
  state["targets"][editor]["versions"] = {
149
245
  k: v for k, v in versions.items() if isinstance(v, str)
150
246
  }
247
+ mcp_ownership = targets.get(editor, {}).get("mcp_ownership", {})
248
+ if isinstance(mcp_ownership, dict):
249
+ state["targets"][editor]["mcp_ownership"] = {
250
+ name: ownership
251
+ for name, ownership in mcp_ownership.items()
252
+ if isinstance(name, str) and isinstance(ownership, dict)
253
+ }
254
+ rule_ownership = targets.get(editor, {}).get("rule_ownership", {})
255
+ if isinstance(rule_ownership, dict):
256
+ state["targets"][editor]["rule_ownership"] = {
257
+ name: ownership
258
+ for name, ownership in rule_ownership.items()
259
+ if isinstance(name, str) and isinstance(ownership, dict)
260
+ }
261
+ _normalize_shared_asset_consumers(state)
151
262
  return state
152
263
 
153
264
 
154
- def save_state(state: dict) -> None:
265
+ def save_state(state: dict) -> FileVersion:
155
266
  """Save installed plugins state."""
156
- PLUGINS_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
157
- with open(PLUGINS_STATE_FILE, "w", encoding="utf-8") as f:
158
- json.dump(state, f, indent=2)
159
- f.write("\n")
267
+ _validate_ancestor_chain(PLUGINS_STATE_FILE)
268
+ _secure_create_parent(PLUGINS_STATE_FILE)
269
+ pins = _pin_ancestors(PLUGINS_STATE_FILE)
270
+ current = _read_file_version(PLUGINS_STATE_FILE)
271
+ mode = current.mode if current is not None else 0o600
272
+ return _secure_atomic_write(
273
+ PLUGINS_STATE_FILE,
274
+ _state_content(state),
275
+ mode,
276
+ pins,
277
+ current,
278
+ )
160
279
 
161
280
 
162
281
  def _installed_for(state: dict, editor: str) -> list[str]:
@@ -168,7 +287,9 @@ def _installed_version(state: dict, editor: str, name: str) -> str:
168
287
 
169
288
 
170
289
  def _record_version(state: dict, editor: str, name: str, version: str) -> None:
171
- state.setdefault("targets", {}).setdefault(editor, {}).setdefault("versions", {})[name] = version
290
+ state.setdefault("targets", {}).setdefault(editor, {}).setdefault("versions", {})[
291
+ name
292
+ ] = version
172
293
 
173
294
 
174
295
  def _forget_version(state: dict, editor: str, name: str) -> None:
@@ -180,10 +301,113 @@ def _set_installed(state: dict, editor: str, names: list[str]) -> None:
180
301
  state["targets"][editor]["installed"] = sorted(set(names))
181
302
 
182
303
 
304
+ def _mcp_ownership_for(state: dict, editor: str, name: str) -> dict | None:
305
+ ownership = (
306
+ state.get("targets", {}).get(editor, {}).get("mcp_ownership", {}).get(name)
307
+ )
308
+ return ownership if isinstance(ownership, dict) else None
309
+
310
+
311
+ def _record_mcp_ownership(state: dict, editor: str, name: str, ownership: dict) -> None:
312
+ target = state.setdefault("targets", {}).setdefault(editor, {})
313
+ target.setdefault("mcp_ownership", {})[name] = ownership
314
+
315
+
316
+ def _forget_mcp_ownership(state: dict, editor: str, name: str) -> None:
317
+ target = state.get("targets", {}).get(editor, {})
318
+ ownership = target.get("mcp_ownership", {})
319
+ if isinstance(ownership, dict):
320
+ ownership.pop(name, None)
321
+
322
+
323
+ def _rule_ownership_for(state: dict, editor: str, name: str) -> dict | None:
324
+ ownership = (
325
+ state.get("targets", {}).get(editor, {}).get("rule_ownership", {}).get(name)
326
+ )
327
+ return ownership if isinstance(ownership, dict) else None
328
+
329
+
330
+ def _record_rule_ownership(
331
+ state: dict, editor: str, name: str, ownership: dict
332
+ ) -> None:
333
+ target = state.setdefault("targets", {}).setdefault(editor, {})
334
+ target.setdefault("rule_ownership", {})[name] = ownership
335
+
336
+
337
+ def _forget_rule_ownership(state: dict, editor: str, name: str) -> None:
338
+ target = state.get("targets", {}).get(editor, {})
339
+ ownership = target.get("rule_ownership", {})
340
+ if isinstance(ownership, dict):
341
+ ownership.pop(name, None)
342
+
343
+
344
+ def _shared_asset_ownership_for(state: dict, name: str) -> dict | None:
345
+ ownership = state.get("shared_asset_ownership", {}).get(name)
346
+ return ownership if isinstance(ownership, dict) else None
347
+
348
+
349
+ def _record_shared_asset_ownership(state: dict, name: str, ownership: dict) -> None:
350
+ state.setdefault("shared_asset_ownership", {})[name] = ownership
351
+
352
+
353
+ def _forget_shared_asset_ownership(state: dict, name: str) -> None:
354
+ ownership = state.get("shared_asset_ownership", {})
355
+ if isinstance(ownership, dict):
356
+ ownership.pop(name, None)
357
+
358
+
359
+ def _shared_asset_consumers(ownership: object, name: str) -> dict[str, list[str]]:
360
+ if not isinstance(ownership, dict):
361
+ return {}
362
+ if ownership.get("source") != f"ai-toolkit-plugin-{name}":
363
+ return {}
364
+ entries = _asset_entries(ownership, name)
365
+ raw = ownership.get("consumers", {})
366
+ if not isinstance(raw, dict):
367
+ return {}
368
+ consumers: dict[str, list[str]] = {}
369
+ for editor, keys in raw.items():
370
+ if editor not in VALID_EDITORS or not isinstance(keys, list):
371
+ continue
372
+ consumers[editor] = sorted(
373
+ key for key in set(keys) if isinstance(key, str) and key in entries
374
+ )
375
+ return consumers
376
+
377
+
378
+ def _release_shared_asset_consumer(state: dict, name: str, editor: str) -> None:
379
+ """Drop one editor and ownership entries no remaining editor consumes."""
380
+ ownership = _shared_asset_ownership_for(state, name)
381
+ if ownership is None:
382
+ return
383
+ consumers = _shared_asset_consumers(ownership, name)
384
+ consumers.pop(editor, None)
385
+ consumers = {key: value for key, value in consumers.items() if value}
386
+ retained_keys = {key for keys in consumers.values() for key in keys}
387
+ entries = {
388
+ key: entry
389
+ for key, entry in _asset_entries(ownership, name).items()
390
+ if key in retained_keys
391
+ }
392
+ if not entries:
393
+ _forget_shared_asset_ownership(state, name)
394
+ return
395
+ _record_shared_asset_ownership(
396
+ state,
397
+ name,
398
+ {
399
+ "source": f"ai-toolkit-plugin-{name}",
400
+ "entries": entries,
401
+ "consumers": consumers,
402
+ },
403
+ )
404
+
405
+
183
406
  # ---------------------------------------------------------------------------
184
407
  # Plugin discovery
185
408
  # ---------------------------------------------------------------------------
186
409
 
410
+
187
411
  def plugin_roots() -> list[Path]:
188
412
  """Directories scanned for packs, in precedence order.
189
413
 
@@ -299,13 +523,15 @@ def _resolve_pack_hooks(pack: dict, pack_dir: Path) -> list[dict]:
299
523
  if not event:
300
524
  print(f" WARN could not infer hook event for: {hook_name}")
301
525
  continue
302
- specs.append({
303
- "ref": hook_ref,
304
- "name": hook_name,
305
- "event": event,
306
- "source": source,
307
- "is_core": is_core,
308
- })
526
+ specs.append(
527
+ {
528
+ "ref": hook_ref,
529
+ "name": hook_name,
530
+ "event": event,
531
+ "source": source,
532
+ "is_core": is_core,
533
+ }
534
+ )
309
535
  seen.add(hook_name)
310
536
  return specs
311
537
 
@@ -318,11 +544,13 @@ def _resolve_pack_rules(pack: dict, pack_dir: Path) -> list[dict]:
318
544
  print(f" WARN rule not found: {rule_name}")
319
545
  continue
320
546
  source, is_core = resolved
321
- specs.append({
322
- "name": source.stem,
323
- "source": source,
324
- "is_core": is_core,
325
- })
547
+ specs.append(
548
+ {
549
+ "name": source.stem,
550
+ "source": source,
551
+ "is_core": is_core,
552
+ }
553
+ )
326
554
  return specs
327
555
 
328
556
 
@@ -334,6 +562,7 @@ def _has_core_claude_rule(rule_name: str) -> bool:
334
562
  # Shared installers
335
563
  # ---------------------------------------------------------------------------
336
564
 
565
+
337
566
  def _ensure_core_hook_scripts() -> None:
338
567
  """Ensure canonical hook scripts exist in ~/.softspark/ai-toolkit/hooks/."""
339
568
  hooks_src = app_dir / "hooks"
@@ -388,7 +617,9 @@ def _copy_plugin_scripts(name: str, pack_dir: Path, installed_items: list[str])
388
617
  break
389
618
 
390
619
 
391
- def _copy_plugin_hook_scripts(name: str, hook_specs: list[dict], installed_items: list[str]) -> None:
620
+ def _copy_plugin_hook_scripts(
621
+ name: str, hook_specs: list[dict], installed_items: list[str]
622
+ ) -> None:
392
623
  """Copy plugin-provided hook scripts into shared toolkit storage."""
393
624
  if not hook_specs:
394
625
  return
@@ -405,8 +636,8 @@ def _copy_plugin_hook_scripts(name: str, hook_specs: list[dict], installed_items
405
636
 
406
637
  def _plugin_hook_command(name: str, spec: dict) -> str:
407
638
  if spec["is_core"]:
408
- return f"\"$HOME/.softspark/ai-toolkit/hooks/{spec['name']}\""
409
- return f"\"$HOME/.softspark/ai-toolkit/hooks/plugin-{name}-{spec['name']}\""
639
+ return f'"$HOME/.softspark/ai-toolkit/hooks/{spec["name"]}"'
640
+ return f'"$HOME/.softspark/ai-toolkit/hooks/plugin-{name}-{spec["name"]}"'
410
641
 
411
642
 
412
643
  def _json_runtime_entry(runtime: str, name: str, spec: dict) -> dict:
@@ -446,23 +677,27 @@ def _json_runtime_set_hooks(runtime: str, document: dict, hooks: dict) -> None:
446
677
  document[cfg.get("root_key") or "hooks"] = hooks
447
678
 
448
679
 
449
- def _merge_json_runtime_hooks(runtime: str, name: str, hook_specs: list[dict]) -> bool:
450
- """Add this pack's hooks to a JSON-configured runtime. True if anything landed."""
680
+ def _merge_json_runtime_hooks_document(
681
+ runtime: str,
682
+ name: str,
683
+ hook_specs: list[dict],
684
+ document: dict,
685
+ ) -> int:
686
+ """Merge one pack's hooks into an already loaded runtime document."""
451
687
  cfg = JSON_HOOK_RUNTIMES[runtime]
452
- config_path: Path = cfg["config"]
453
- if config_path.is_symlink() or config_path.parent.is_symlink():
454
- print(f" WARN refusing symlinked {runtime} config: {config_path}")
455
- return False
456
-
457
688
  source_tag = f"ai-toolkit-plugin-{name}"
458
- document = _load_json(config_path, {})
459
689
  hooks = _json_runtime_hooks_block(runtime, document)
460
690
 
461
691
  # Drop this pack's previous entries everywhere before appending, so a
462
692
  # re-install cannot duplicate and two hooks on one event both survive.
463
693
  hooks = {
464
- event: [e for e in entries if not (isinstance(e, dict) and e.get("_source") == source_tag)]
465
- if isinstance(entries, list) else entries
694
+ event: [
695
+ e
696
+ for e in entries
697
+ if not (isinstance(e, dict) and e.get("_source") == source_tag)
698
+ ]
699
+ if isinstance(entries, list)
700
+ else entries
466
701
  for event, entries in hooks.items()
467
702
  }
468
703
 
@@ -477,26 +712,23 @@ def _merge_json_runtime_hooks(runtime: str, name: str, hook_specs: list[dict]) -
477
712
  f"for {spec['name']}, hook not registered"
478
713
  )
479
714
  continue
480
- hooks.setdefault(target_event, []).append(_json_runtime_entry(runtime, name, spec))
715
+ hooks.setdefault(target_event, []).append(
716
+ _json_runtime_entry(runtime, name, spec)
717
+ )
481
718
  landed += 1
482
719
 
483
- if not landed:
484
- return False
485
-
486
720
  hooks = {event: entries for event, entries in hooks.items() if entries}
487
721
  _json_runtime_set_hooks(runtime, document, hooks)
488
- _write_json(config_path, document)
489
- print(f" Merged hooks into {config_path}")
490
- return True
722
+ return landed
491
723
 
492
724
 
493
- def _strip_json_runtime_hooks(runtime: str, name: str) -> None:
494
- cfg = JSON_HOOK_RUNTIMES[runtime]
495
- config_path: Path = cfg["config"]
496
- if not config_path.is_file() or config_path.is_symlink():
497
- return
725
+ def _strip_json_runtime_hooks_document(
726
+ runtime: str,
727
+ name: str,
728
+ document: dict,
729
+ ) -> int:
730
+ """Strip one pack's hooks from an already loaded runtime document."""
498
731
  source_tag = f"ai-toolkit-plugin-{name}"
499
- document = _load_json(config_path, {})
500
732
  hooks = _json_runtime_hooks_block(runtime, document)
501
733
  kept = {}
502
734
  removed = 0
@@ -505,42 +737,1634 @@ def _strip_json_runtime_hooks(runtime: str, name: str) -> None:
505
737
  kept[event] = entries
506
738
  continue
507
739
  survivors = [
508
- e for e in entries
740
+ e
741
+ for e in entries
509
742
  if not (isinstance(e, dict) and e.get("_source") == source_tag)
510
743
  ]
511
744
  removed += len(entries) - len(survivors)
512
745
  if survivors:
513
746
  kept[event] = survivors
514
- if not removed:
515
- return
516
747
  _json_runtime_set_hooks(runtime, document, kept)
517
- _write_json(config_path, document)
518
- print(f" Stripped hooks from {config_path}")
748
+ return removed
519
749
 
520
750
 
521
- def install_pack_json_runtime(runtime: str, name: str, pack: dict, pack_dir: Path) -> bool:
522
- hook_specs = _resolve_pack_hooks(pack, pack_dir)
523
- installed_items: list[str] = []
524
- _copy_plugin_scripts(name, pack_dir, installed_items)
525
- _copy_plugin_hook_scripts(name, hook_specs, installed_items)
526
- if not _merge_json_runtime_hooks(runtime, name, hook_specs):
527
- print(f" WARN nothing registered for {runtime}; pack files are installed but inert")
528
- print(f" Done: {name} for {runtime} ({len(installed_items)} file items)")
751
+ def _load_json_runtime_document(path: Path) -> tuple[bytes | None, dict]:
752
+ version = _read_file_version(path)
753
+ if version is None:
754
+ return None, {}
755
+ try:
756
+ document = json.loads(version.content.decode("utf-8"))
757
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
758
+ raise ValueError(f"Invalid JSON runtime config at {path}: {error}") from error
759
+ if not isinstance(document, dict):
760
+ raise ValueError(f"JSON runtime config must be an object: {path}")
761
+ return version.content, document
762
+
763
+
764
+ def _prepare_json_runtime_hook_install(
765
+ runtime: str,
766
+ name: str,
767
+ hook_specs: list[dict],
768
+ ) -> ConfigUpdate | None:
769
+ path: Path = JSON_HOOK_RUNTIMES[runtime]["config"]
770
+ original, document = _load_json_runtime_document(path)
771
+ if not _merge_json_runtime_hooks_document(runtime, name, hook_specs, document):
772
+ return None
773
+ content = (json.dumps(document, indent=4) + "\n").encode("utf-8")
774
+ return ConfigUpdate(path=path, original=original, content=content)
775
+
776
+
777
+ def _prepare_json_runtime_hook_removal(
778
+ runtime: str,
779
+ name: str,
780
+ ) -> ConfigUpdate | None:
781
+ path: Path = JSON_HOOK_RUNTIMES[runtime]["config"]
782
+ original, document = _load_json_runtime_document(path)
783
+ if original is None:
784
+ return None
785
+ if not _strip_json_runtime_hooks_document(runtime, name, document):
786
+ return None
787
+ content = (json.dumps(document, indent=4) + "\n").encode("utf-8")
788
+ return ConfigUpdate(path=path, original=original, content=content)
789
+
790
+
791
+ def _gemini_mcp_update(
792
+ updates: tuple[ConfigUpdate, ...],
793
+ ) -> ConfigUpdate:
794
+ """Return the single Gemini settings update owned by an MCP plan."""
795
+ if len(updates) != 1:
796
+ raise RuntimeError("Gemini MCP plan must target exactly one settings file")
797
+ update = updates[0]
798
+ expected = JSON_HOOK_RUNTIMES["gemini"]["config"]
799
+ if update.path != expected or update.content is None:
800
+ raise RuntimeError("Gemini MCP plan does not target the shared settings file")
801
+ return update
802
+
803
+
804
+ def _prepare_gemini_combined_install(
805
+ name: str,
806
+ hook_specs: list[dict],
807
+ mcp_plan: PluginMcpInstallPlan | None,
808
+ ) -> ConfigUpdate | None:
809
+ """Combine Gemini MCP and hook changes against one settings snapshot."""
810
+ if mcp_plan is None or not hook_specs:
811
+ return None
812
+ update = _gemini_mcp_update(mcp_plan.updates)
813
+ document = json.loads(update.content.decode("utf-8"))
814
+ if not _merge_json_runtime_hooks_document("gemini", name, hook_specs, document):
815
+ return None
816
+ content = (json.dumps(document, indent=4) + "\n").encode("utf-8")
817
+ return ConfigUpdate(path=update.path, original=update.original, content=content)
818
+
819
+
820
+ def _prepare_gemini_combined_removal(
821
+ name: str,
822
+ mcp_plan: PluginMcpRemovalPlan | None,
823
+ ) -> ConfigUpdate | None:
824
+ """Combine Gemini MCP and hook removal against one settings snapshot."""
825
+ if mcp_plan is None or not mcp_plan.updates:
826
+ return None
827
+ update = _gemini_mcp_update(mcp_plan.updates)
828
+ document = json.loads(update.content.decode("utf-8"))
829
+ if not _strip_json_runtime_hooks_document("gemini", name, document):
830
+ return None
831
+ content = (json.dumps(document, indent=4) + "\n").encode("utf-8")
832
+ return ConfigUpdate(path=update.path, original=update.original, content=content)
833
+
834
+
835
+ def _print_mcp_install_result(plan: PluginMcpInstallPlan) -> None:
836
+ for server_name in plan.ownership["servers"]:
837
+ print(f" Installed MCP server: {server_name}")
838
+ for hint in plan.hints:
839
+ print(f" MCP note: {hint}")
840
+
841
+
842
+ def _print_mcp_removal_result(plan: PluginMcpRemovalPlan) -> None:
843
+ for server_name in plan.removed:
844
+ print(f" Removed MCP server: {server_name}")
845
+ for server_name in plan.preserved:
846
+ print(f" WARN preserved changed or user-owned MCP server: {server_name}")
847
+
848
+
849
+ @dataclass(frozen=True, slots=True)
850
+ class FileVersion:
851
+ """Exact no-follow identity and bytes for one regular file."""
852
+
853
+ content: bytes
854
+ mode: int
855
+ device: int
856
+ inode: int
857
+
858
+
859
+ @dataclass(frozen=True, slots=True)
860
+ class AncestorPin:
861
+ """Pinned directory identity from HOME to one destination parent."""
862
+
863
+ path: Path
864
+ device: int
865
+ inode: int
866
+
867
+
868
+ @dataclass(slots=True)
869
+ class FileMutation:
870
+ """Before/expected/produced states for CAS rollback."""
871
+
872
+ path: Path
873
+ ancestors: tuple[AncestorPin, ...]
874
+ before: FileVersion | None
875
+ expected_content: bytes | None = None
876
+ expected_mode: int | None = None
877
+ expected_is_set: bool = False
878
+ produced_is_recorded: bool = False
879
+ produced_history: list[FileVersion | None] = field(default_factory=list)
880
+ backup_path: Path | None = None
881
+
882
+
883
+ @dataclass(frozen=True, slots=True)
884
+ class AssetSpec:
885
+ """One explicit plugin-owned hook or script file installation."""
886
+
887
+ key: str
888
+ path: Path
889
+ content: bytes
890
+ mode: int
891
+ kind: str
892
+
893
+
894
+ @dataclass(frozen=True, slots=True)
895
+ class AssetRemovalPlan:
896
+ """Identity-checked shared asset removals and preserved entries."""
897
+
898
+ removable: tuple[tuple[str, Path, FileVersion], ...]
899
+ preserved: tuple[tuple[str, Path], ...]
900
+
901
+
902
+ _UNSPECIFIED_FILE_VERSION = object()
903
+
904
+
905
+ def _home_relative(path: Path) -> Path:
906
+ absolute = path.expanduser().absolute()
907
+ home = Path.home().absolute()
908
+ try:
909
+ return absolute.relative_to(home)
910
+ except ValueError as error:
911
+ raise RuntimeError(f"Plugin path escapes expected HOME: {absolute}") from error
912
+
913
+
914
+ def _lstat_directory(path: Path) -> os.stat_result:
915
+ info = os.lstat(path)
916
+ if stat.S_ISLNK(info.st_mode):
917
+ raise RuntimeError(f"Refusing symlinked plugin path ancestor: {path}")
918
+ if not stat.S_ISDIR(info.st_mode):
919
+ raise RuntimeError(f"Plugin path ancestor is not a directory: {path}")
920
+ return info
921
+
922
+
923
+ def _validate_ancestor_chain(path: Path) -> None:
924
+ relative = _home_relative(path)
925
+ current = Path.home().absolute()
926
+ _lstat_directory(current)
927
+ for part in relative.parts[:-1]:
928
+ current /= part
929
+ try:
930
+ _lstat_directory(current)
931
+ except FileNotFoundError:
932
+ continue
933
+
934
+
935
+ def _secure_create_parent(path: Path) -> None:
936
+ relative = _home_relative(path)
937
+ current_path = Path.home().absolute()
938
+ _lstat_directory(current_path)
939
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
940
+ flags |= getattr(os, "O_NOFOLLOW", 0)
941
+ current = os.open(current_path, flags)
942
+ try:
943
+ for part in relative.parts[:-1]:
944
+ current_path /= part
945
+ try:
946
+ child = os.open(part, flags, dir_fd=current)
947
+ except FileNotFoundError:
948
+ try:
949
+ os.mkdir(part, 0o700, dir_fd=current)
950
+ except FileExistsError:
951
+ pass
952
+ try:
953
+ child = os.open(part, flags, dir_fd=current)
954
+ except OSError as error:
955
+ raise RuntimeError(
956
+ f"Unsafe plugin path ancestor: {current_path}"
957
+ ) from error
958
+ except OSError as error:
959
+ raise RuntimeError(
960
+ f"Unsafe plugin path ancestor: {current_path}"
961
+ ) from error
962
+ os.close(current)
963
+ current = child
964
+ finally:
965
+ os.close(current)
966
+
967
+
968
+ def _pin_ancestors(path: Path) -> tuple[AncestorPin, ...]:
969
+ relative = _home_relative(path)
970
+ current_path = Path.home().absolute()
971
+ pins: list[AncestorPin] = []
972
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
973
+ flags |= getattr(os, "O_NOFOLLOW", 0)
974
+ current = os.open(current_path, flags)
975
+ try:
976
+ info = os.fstat(current)
977
+ pins.append(AncestorPin(current_path, info.st_dev, info.st_ino))
978
+ for part in relative.parts[:-1]:
979
+ current_path /= part
980
+ child = os.open(part, flags, dir_fd=current)
981
+ os.close(current)
982
+ current = child
983
+ info = os.fstat(current)
984
+ pins.append(AncestorPin(current_path, info.st_dev, info.st_ino))
985
+ finally:
986
+ os.close(current)
987
+ return tuple(pins)
988
+
989
+
990
+ def _verify_ancestor_pins(pins: tuple[AncestorPin, ...]) -> None:
991
+ if not pins:
992
+ raise RuntimeError("Plugin path has no ancestor pins")
993
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
994
+ flags |= getattr(os, "O_NOFOLLOW", 0)
995
+ current = os.open(pins[0].path, flags)
996
+ try:
997
+ for index, pin in enumerate(pins):
998
+ if index:
999
+ child = os.open(pin.path.name, flags, dir_fd=current)
1000
+ os.close(current)
1001
+ current = child
1002
+ info = os.fstat(current)
1003
+ if (info.st_dev, info.st_ino) != (pin.device, pin.inode):
1004
+ raise RuntimeError(f"Plugin path identity changed: {pin.path}")
1005
+ except OSError as error:
1006
+ raise RuntimeError(f"Plugin path identity changed: {pin.path}") from error
1007
+ finally:
1008
+ os.close(current)
1009
+
1010
+
1011
+ def _read_file_version(path: Path) -> FileVersion | None:
1012
+ flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
1013
+ try:
1014
+ descriptor = os.open(path, flags)
1015
+ except FileNotFoundError:
1016
+ return None
1017
+ try:
1018
+ info = os.fstat(descriptor)
1019
+ if not stat.S_ISREG(info.st_mode):
1020
+ raise RuntimeError(f"Plugin destination is not a regular file: {path}")
1021
+ chunks: list[bytes] = []
1022
+ while True:
1023
+ chunk = os.read(descriptor, 1024 * 1024)
1024
+ if not chunk:
1025
+ break
1026
+ chunks.append(chunk)
1027
+ return FileVersion(
1028
+ content=b"".join(chunks),
1029
+ mode=info.st_mode & 0o777,
1030
+ device=info.st_dev,
1031
+ inode=info.st_ino,
1032
+ )
1033
+ finally:
1034
+ os.close(descriptor)
1035
+
1036
+
1037
+ def _read_file_version_at(directory: int, name: str) -> FileVersion | None:
1038
+ """Read one regular file relative to an already pinned directory."""
1039
+ flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
1040
+ try:
1041
+ descriptor = os.open(name, flags, dir_fd=directory)
1042
+ except FileNotFoundError:
1043
+ return None
1044
+ try:
1045
+ info = os.fstat(descriptor)
1046
+ if not stat.S_ISREG(info.st_mode):
1047
+ raise RuntimeError(f"Plugin destination is not a regular file: {name}")
1048
+ chunks: list[bytes] = []
1049
+ while True:
1050
+ chunk = os.read(descriptor, 1024 * 1024)
1051
+ if not chunk:
1052
+ break
1053
+ chunks.append(chunk)
1054
+ return FileVersion(
1055
+ content=b"".join(chunks),
1056
+ mode=info.st_mode & 0o777,
1057
+ device=info.st_dev,
1058
+ inode=info.st_ino,
1059
+ )
1060
+ finally:
1061
+ os.close(descriptor)
1062
+
1063
+
1064
+ def _open_pinned_parent(path: Path, pins: tuple[AncestorPin, ...]) -> int:
1065
+ """Open and verify the destination parent represented by ancestor pins."""
1066
+ _verify_ancestor_pins(pins)
1067
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
1068
+ flags |= getattr(os, "O_NOFOLLOW", 0)
1069
+ directory = os.open(path.parent, flags)
1070
+ info = os.fstat(directory)
1071
+ parent_pin = pins[-1]
1072
+ if (info.st_dev, info.st_ino) != (parent_pin.device, parent_pin.inode):
1073
+ os.close(directory)
1074
+ raise RuntimeError(f"Plugin path identity changed: {parent_pin.path}")
1075
+ return directory
1076
+
1077
+
1078
+ def _restore_quarantined_file(
1079
+ directory: int,
1080
+ quarantine_name: str,
1081
+ destination_name: str,
1082
+ moved: FileVersion,
1083
+ ) -> None:
1084
+ """Restore a quarantined inode without replacing a concurrent destination."""
1085
+ if _read_file_version_at(directory, quarantine_name) != moved:
1086
+ raise RuntimeError(
1087
+ f"Quarantined plugin file changed; retained as {quarantine_name}"
1088
+ )
1089
+ try:
1090
+ os.link(
1091
+ quarantine_name,
1092
+ destination_name,
1093
+ src_dir_fd=directory,
1094
+ dst_dir_fd=directory,
1095
+ follow_symlinks=False,
1096
+ )
1097
+ except FileExistsError as error:
1098
+ raise RuntimeError(
1099
+ "Concurrent plugin replacement preserved; original retained as "
1100
+ f"{quarantine_name}"
1101
+ ) from error
1102
+ restored = _read_file_version_at(directory, destination_name)
1103
+ if restored != moved:
1104
+ raise RuntimeError(
1105
+ f"Plugin quarantine restore identity mismatch: {destination_name}"
1106
+ )
1107
+ if _read_file_version_at(directory, quarantine_name) != moved:
1108
+ raise RuntimeError(
1109
+ f"Quarantined plugin file changed; retained as {quarantine_name}"
1110
+ )
1111
+ os.unlink(quarantine_name, dir_fd=directory)
1112
+
1113
+
1114
+ def _before_restore_backup_exchange(
1115
+ mutation: FileMutation,
1116
+ expected_current: FileVersion | None,
1117
+ ) -> None:
1118
+ """Test seam immediately before the rollback restore CAS boundary."""
1119
+
1120
+
1121
+ def _secure_atomic_write(
1122
+ path: Path,
1123
+ content: bytes,
1124
+ mode: int,
1125
+ pins: tuple[AncestorPin, ...],
1126
+ expected_before: FileVersion | None | object = _UNSPECIFIED_FILE_VERSION,
1127
+ ) -> FileVersion:
1128
+ directory = _open_pinned_parent(path, pins)
1129
+ temp_name = f".{path.name}.{secrets.token_hex(8)}.tmp"
1130
+ quarantine_name: str | None = None
1131
+ descriptor = -1
1132
+ try:
1133
+ current_before = _read_file_version_at(directory, path.name)
1134
+ expected = (
1135
+ current_before
1136
+ if expected_before is _UNSPECIFIED_FILE_VERSION
1137
+ else expected_before
1138
+ )
1139
+ if current_before != expected:
1140
+ raise RuntimeError(f"Plugin file changed before write: {path}")
1141
+ descriptor = os.open(
1142
+ temp_name,
1143
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
1144
+ mode,
1145
+ dir_fd=directory,
1146
+ )
1147
+ os.fchmod(descriptor, mode)
1148
+ view = memoryview(content)
1149
+ while view:
1150
+ written = os.write(descriptor, view)
1151
+ view = view[written:]
1152
+ os.fsync(descriptor)
1153
+ temp_info = os.fstat(descriptor)
1154
+ temp_version = FileVersion(
1155
+ content=content,
1156
+ mode=temp_info.st_mode & 0o777,
1157
+ device=temp_info.st_dev,
1158
+ inode=temp_info.st_ino,
1159
+ )
1160
+ os.close(descriptor)
1161
+ descriptor = -1
1162
+ if expected is None:
1163
+ os.link(
1164
+ temp_name,
1165
+ path.name,
1166
+ src_dir_fd=directory,
1167
+ dst_dir_fd=directory,
1168
+ follow_symlinks=False,
1169
+ )
1170
+ os.unlink(temp_name, dir_fd=directory)
1171
+ else:
1172
+ quarantine_name = f".{path.name}.{secrets.token_hex(8)}.quarantine"
1173
+ os.rename(
1174
+ path.name,
1175
+ quarantine_name,
1176
+ src_dir_fd=directory,
1177
+ dst_dir_fd=directory,
1178
+ )
1179
+ moved = _read_file_version_at(directory, quarantine_name)
1180
+ if moved != expected:
1181
+ if moved is not None:
1182
+ _restore_quarantined_file(
1183
+ directory,
1184
+ quarantine_name,
1185
+ path.name,
1186
+ moved,
1187
+ )
1188
+ quarantine_name = None
1189
+ raise RuntimeError(f"Plugin file changed before replace: {path}")
1190
+ try:
1191
+ os.link(
1192
+ temp_name,
1193
+ path.name,
1194
+ src_dir_fd=directory,
1195
+ dst_dir_fd=directory,
1196
+ follow_symlinks=False,
1197
+ )
1198
+ except FileExistsError as error:
1199
+ raise RuntimeError(
1200
+ "Concurrent plugin replacement preserved; original retained as "
1201
+ f"{path.parent / quarantine_name}"
1202
+ ) from error
1203
+ installed = _read_file_version_at(directory, path.name)
1204
+ if installed != temp_version:
1205
+ raise RuntimeError(f"Plugin write identity mismatch: {path}")
1206
+ os.unlink(temp_name, dir_fd=directory)
1207
+ if _read_file_version_at(directory, quarantine_name) != moved:
1208
+ raise RuntimeError(
1209
+ "Plugin quarantine changed; original retained as "
1210
+ f"{path.parent / quarantine_name}"
1211
+ )
1212
+ os.unlink(quarantine_name, dir_fd=directory)
1213
+ quarantine_name = None
1214
+ os.fsync(directory)
1215
+ version = _read_file_version_at(directory, path.name)
1216
+ if version is None: # pragma: no cover - link succeeded above
1217
+ raise RuntimeError(f"Plugin write disappeared: {path}")
1218
+ return version
1219
+ finally:
1220
+ if descriptor >= 0:
1221
+ os.close(descriptor)
1222
+ try:
1223
+ os.unlink(temp_name, dir_fd=directory)
1224
+ except FileNotFoundError:
1225
+ pass
1226
+ os.close(directory)
1227
+
1228
+
1229
+ def _secure_unlink(
1230
+ path: Path,
1231
+ expected: FileVersion,
1232
+ pins: tuple[AncestorPin, ...],
1233
+ ) -> None:
1234
+ directory = _open_pinned_parent(path, pins)
1235
+ quarantine_name = f".{path.name}.{secrets.token_hex(8)}.quarantine"
1236
+ try:
1237
+ if _read_file_version_at(directory, path.name) != expected:
1238
+ raise RuntimeError(f"Plugin file changed before delete: {path}")
1239
+ os.rename(
1240
+ path.name,
1241
+ quarantine_name,
1242
+ src_dir_fd=directory,
1243
+ dst_dir_fd=directory,
1244
+ )
1245
+ moved = _read_file_version_at(directory, quarantine_name)
1246
+ if moved != expected:
1247
+ if moved is not None:
1248
+ _restore_quarantined_file(
1249
+ directory,
1250
+ quarantine_name,
1251
+ path.name,
1252
+ moved,
1253
+ )
1254
+ raise RuntimeError(f"Plugin file changed before delete: {path}")
1255
+ if _read_file_version_at(directory, quarantine_name) != expected:
1256
+ raise RuntimeError(
1257
+ "Plugin quarantine changed; file retained as "
1258
+ f"{path.parent / quarantine_name}"
1259
+ )
1260
+ os.unlink(quarantine_name, dir_fd=directory)
1261
+ os.fsync(directory)
1262
+ finally:
1263
+ os.close(directory)
1264
+
1265
+
1266
+ @contextmanager
1267
+ def _exclusive_plugin_lock(path: Path, label: str):
1268
+ """Hold one no-follow, pinned advisory lock for the current process."""
1269
+ _validate_ancestor_chain(path)
1270
+ _secure_create_parent(path)
1271
+ pins = _pin_ancestors(path)
1272
+ _verify_ancestor_pins(pins)
1273
+ no_follow = getattr(os, "O_NOFOLLOW", 0)
1274
+ create_flags = os.O_RDWR | os.O_CREAT | os.O_EXCL | no_follow
1275
+ open_flags = os.O_RDWR | no_follow
1276
+ directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
1277
+ directory_flags |= getattr(os, "O_NOFOLLOW", 0)
1278
+ directory = os.open(path.parent, directory_flags)
1279
+ try:
1280
+ directory_info = os.fstat(directory)
1281
+ parent_pin = pins[-1]
1282
+ if (directory_info.st_dev, directory_info.st_ino) != (
1283
+ parent_pin.device,
1284
+ parent_pin.inode,
1285
+ ):
1286
+ raise RuntimeError(f"Plugin {label} lock parent changed: {parent_pin.path}")
1287
+ for attempt in range(3):
1288
+ try:
1289
+ descriptor = os.open(
1290
+ path.name,
1291
+ create_flags,
1292
+ 0o600,
1293
+ dir_fd=directory,
1294
+ )
1295
+ break
1296
+ except FileExistsError:
1297
+ try:
1298
+ descriptor = os.open(
1299
+ path.name,
1300
+ open_flags,
1301
+ dir_fd=directory,
1302
+ )
1303
+ break
1304
+ except FileNotFoundError:
1305
+ if attempt == 2:
1306
+ raise
1307
+ else: # pragma: no cover - loop always breaks or raises
1308
+ raise RuntimeError(f"Unable to open plugin {label} lock: {path}")
1309
+ finally:
1310
+ os.close(directory)
1311
+ info = os.fstat(descriptor)
1312
+ if not stat.S_ISREG(info.st_mode):
1313
+ os.close(descriptor)
1314
+ raise RuntimeError(f"Plugin {label} lock is not a regular file: {path}")
1315
+ if fcntl is not None:
1316
+ fcntl.flock(descriptor, fcntl.LOCK_EX)
1317
+ else: # pragma: no cover - Windows only
1318
+ msvcrt.locking(descriptor, msvcrt.LK_LOCK, 1)
1319
+ try:
1320
+ yield descriptor
1321
+ finally:
1322
+ if fcntl is not None:
1323
+ fcntl.flock(descriptor, fcntl.LOCK_UN)
1324
+ else: # pragma: no cover - Windows only
1325
+ msvcrt.locking(descriptor, msvcrt.LK_UNLCK, 1)
1326
+ os.close(descriptor)
1327
+
1328
+
1329
+ @contextmanager
1330
+ def _plugin_lifecycle_lock():
1331
+ """Serialize plugin lifecycle commands across processes and nested calls."""
1332
+ global _PLUGIN_LOCK_DEPTH
1333
+ with _PLUGIN_THREAD_LOCK:
1334
+ if _PLUGIN_LOCK_DEPTH:
1335
+ _PLUGIN_LOCK_DEPTH += 1
1336
+ try:
1337
+ yield
1338
+ finally:
1339
+ _PLUGIN_LOCK_DEPTH -= 1
1340
+ return
1341
+
1342
+ with _exclusive_plugin_lock(PLUGIN_LIFECYCLE_LOCK, "lifecycle"):
1343
+ _PLUGIN_LOCK_DEPTH = 1
1344
+ try:
1345
+ yield
1346
+ finally:
1347
+ _PLUGIN_LOCK_DEPTH = 0
1348
+
1349
+
1350
+ @contextmanager
1351
+ def _plugin_operation_gate():
1352
+ """Serialize normal lifecycle plus init while letting marked init children reenter."""
1353
+ if os.environ.get("AI_TOOLKIT_PLUGIN_INIT_ACTIVE") == "1":
1354
+ yield
1355
+ return
1356
+ with _exclusive_plugin_lock(PLUGIN_INIT_LOCK, "init"):
1357
+ yield
1358
+
1359
+
1360
+ class PluginFileTransaction:
1361
+ """CAS rollback boundary for one plugin lifecycle operation."""
1362
+
1363
+ def __init__(self, paths: tuple[Path, ...]) -> None:
1364
+ unique_paths = tuple(sorted(set(paths), key=str))
1365
+ for path in unique_paths:
1366
+ _validate_ancestor_chain(path)
1367
+ for path in unique_paths:
1368
+ _secure_create_parent(path)
1369
+ self.mutations = {
1370
+ path: FileMutation(
1371
+ path=path,
1372
+ ancestors=_pin_ancestors(path),
1373
+ before=_read_file_version(path),
1374
+ )
1375
+ for path in unique_paths
1376
+ }
1377
+
1378
+ def expect_file(self, path: Path, content: bytes, mode: int | None = None) -> None:
1379
+ mutation = self.mutations[path]
1380
+ mutation.expected_content = content
1381
+ mutation.expected_mode = (
1382
+ mode
1383
+ if mode is not None
1384
+ else (mutation.before.mode if mutation.before is not None else 0o600)
1385
+ )
1386
+ mutation.expected_is_set = True
1387
+
1388
+ def expect_absent(self, path: Path) -> None:
1389
+ mutation = self.mutations[path]
1390
+ mutation.expected_content = None
1391
+ mutation.expected_mode = None
1392
+ mutation.expected_is_set = True
1393
+
1394
+ def backup(self, path: Path) -> None:
1395
+ mutation = self.mutations[path]
1396
+ if mutation.backup_path is not None:
1397
+ return
1398
+ if mutation.before is None:
1399
+ if _read_file_version(path) is not None:
1400
+ raise RuntimeError(
1401
+ f"Concurrent create at plugin destination preserved: {path}"
1402
+ )
1403
+ return
1404
+ _verify_ancestor_pins(mutation.ancestors)
1405
+ current = _read_file_version(path)
1406
+ if current != mutation.before:
1407
+ raise RuntimeError(f"Plugin file changed before backup: {path}")
1408
+ directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
1409
+ directory_flags |= getattr(os, "O_NOFOLLOW", 0)
1410
+ directory = os.open(path.parent, directory_flags)
1411
+ backup_name = f".{path.name}.{secrets.token_hex(8)}.rollback"
1412
+ try:
1413
+ directory_info = os.fstat(directory)
1414
+ parent_pin = mutation.ancestors[-1]
1415
+ if (directory_info.st_dev, directory_info.st_ino) != (
1416
+ parent_pin.device,
1417
+ parent_pin.inode,
1418
+ ):
1419
+ raise RuntimeError(f"Plugin path identity changed: {parent_pin.path}")
1420
+ os.link(
1421
+ path.name,
1422
+ backup_name,
1423
+ src_dir_fd=directory,
1424
+ dst_dir_fd=directory,
1425
+ follow_symlinks=False,
1426
+ )
1427
+ os.fsync(directory)
1428
+ finally:
1429
+ os.close(directory)
1430
+ backup_path = path.parent / backup_name
1431
+ backup = _read_file_version(backup_path)
1432
+ if backup != mutation.before:
1433
+ raise RuntimeError(f"Plugin rollback backup identity mismatch: {path}")
1434
+ mutation.backup_path = backup_path
1435
+
1436
+ def record(self, path: Path) -> None:
1437
+ mutation = self.mutations[path]
1438
+ _verify_ancestor_pins(mutation.ancestors)
1439
+ current = _read_file_version(path)
1440
+ if mutation.expected_content is None:
1441
+ if current is not None:
1442
+ raise RuntimeError(
1443
+ f"Expected plugin file removal did not occur: {path}"
1444
+ )
1445
+ elif (
1446
+ current is None
1447
+ or current.content != mutation.expected_content
1448
+ or current.mode != mutation.expected_mode
1449
+ ):
1450
+ raise RuntimeError(f"Plugin mutation produced unexpected state: {path}")
1451
+ mutation.produced_is_recorded = True
1452
+ mutation.produced_history.append(current)
1453
+
1454
+ def record_version(self, path: Path, version: FileVersion) -> None:
1455
+ mutation = self.mutations[path]
1456
+ _verify_ancestor_pins(mutation.ancestors)
1457
+ current = _read_file_version(path)
1458
+ if current != version:
1459
+ raise RuntimeError(f"Plugin file changed immediately after write: {path}")
1460
+ if (
1461
+ mutation.expected_content != version.content
1462
+ or mutation.expected_mode != version.mode
1463
+ ):
1464
+ raise RuntimeError(f"Plugin mutation produced unexpected state: {path}")
1465
+ mutation.produced_is_recorded = True
1466
+ mutation.produced_history.append(version)
1467
+
1468
+ def final_exact_states(self) -> dict[Path, FileVersion | None]:
1469
+ """Return only final states proven by this transaction."""
1470
+ states: dict[Path, FileVersion | None] = {}
1471
+ for mutation in self.mutations.values():
1472
+ _verify_ancestor_pins(mutation.ancestors)
1473
+ current = _read_file_version(mutation.path)
1474
+ if current == mutation.before or any(
1475
+ current == produced for produced in mutation.produced_history
1476
+ ):
1477
+ states[mutation.path] = current
1478
+ return states
1479
+
1480
+ def accept_nested_states(
1481
+ self,
1482
+ states: dict[Path, FileVersion | None],
1483
+ ) -> None:
1484
+ """Adopt exact inode states proven by a nested lifecycle transaction."""
1485
+ for path, state in states.items():
1486
+ mutation = self.mutations.get(path)
1487
+ if mutation is None or not mutation.expected_is_set:
1488
+ continue
1489
+ if mutation.expected_content is None:
1490
+ if state is None:
1491
+ mutation.produced_history.append(None)
1492
+ mutation.produced_is_recorded = True
1493
+ continue
1494
+ if (
1495
+ state is not None
1496
+ and state.content == mutation.expected_content
1497
+ and state.mode == mutation.expected_mode
1498
+ ):
1499
+ mutation.produced_history.append(state)
1500
+ mutation.produced_is_recorded = True
1501
+
1502
+ def rollback(self) -> None:
1503
+ conflicts: list[str] = []
1504
+ for mutation in reversed(tuple(self.mutations.values())):
1505
+ if not mutation.expected_is_set:
1506
+ continue
1507
+ try:
1508
+ _verify_ancestor_pins(mutation.ancestors)
1509
+ current = _read_file_version(mutation.path)
1510
+ except Exception as error:
1511
+ conflicts.append(f"{mutation.path}: {error}")
1512
+ continue
1513
+ if current == mutation.before:
1514
+ mutation.produced_history.append(current)
1515
+ self._discard_backup(mutation, conflicts)
1516
+ continue
1517
+ if not self._matches_produced(mutation, current):
1518
+ recovery = (
1519
+ f"; original retained at {mutation.backup_path}"
1520
+ if mutation.backup_path is not None
1521
+ else ""
1522
+ )
1523
+ conflicts.append(
1524
+ f"{mutation.path}: concurrent edit/create/mode/path swap preserved"
1525
+ f"{recovery}"
1526
+ )
1527
+ continue
1528
+ try:
1529
+ if mutation.before is None:
1530
+ if current is not None:
1531
+ _secure_unlink(
1532
+ mutation.path,
1533
+ current,
1534
+ mutation.ancestors,
1535
+ )
1536
+ mutation.produced_history.append(None)
1537
+ else:
1538
+ if mutation.backup_path is not None:
1539
+ self._restore_backup(mutation, current)
1540
+ mutation.produced_history.append(mutation.before)
1541
+ else:
1542
+ restored = _secure_atomic_write(
1543
+ mutation.path,
1544
+ mutation.before.content,
1545
+ mutation.before.mode,
1546
+ mutation.ancestors,
1547
+ current,
1548
+ )
1549
+ mutation.produced_history.append(restored)
1550
+ except Exception as error:
1551
+ conflicts.append(f"{mutation.path}: {error}")
1552
+ if conflicts:
1553
+ raise RuntimeError("Rollback conflict(s): " + "; ".join(conflicts))
1554
+
1555
+ def commit(self) -> None:
1556
+ conflicts: list[str] = []
1557
+ for mutation in self.mutations.values():
1558
+ self._discard_backup(mutation, conflicts)
1559
+ if conflicts:
1560
+ raise RuntimeError(
1561
+ "Plugin backup cleanup conflict(s): " + "; ".join(conflicts)
1562
+ )
1563
+
1564
+ @staticmethod
1565
+ def _restore_backup(
1566
+ mutation: FileMutation,
1567
+ expected_current: FileVersion | None,
1568
+ ) -> None:
1569
+ backup_path = mutation.backup_path
1570
+ if backup_path is None or mutation.before is None:
1571
+ raise RuntimeError(f"Missing plugin rollback backup: {mutation.path}")
1572
+ backup = _read_file_version(backup_path)
1573
+ if backup != mutation.before:
1574
+ raise RuntimeError(f"Plugin rollback backup changed: {backup_path}")
1575
+ directory = _open_pinned_parent(mutation.path, mutation.ancestors)
1576
+ quarantine_name: str | None = None
1577
+ try:
1578
+ if _read_file_version_at(directory, backup_path.name) != mutation.before:
1579
+ raise RuntimeError(f"Plugin rollback backup changed: {backup_path}")
1580
+ if _read_file_version_at(directory, mutation.path.name) != expected_current:
1581
+ raise RuntimeError(
1582
+ f"Plugin file changed before rollback restore: {mutation.path}"
1583
+ )
1584
+ _before_restore_backup_exchange(mutation, expected_current)
1585
+ if expected_current is not None:
1586
+ quarantine_name = (
1587
+ f".{mutation.path.name}.{secrets.token_hex(8)}.rollback-current"
1588
+ )
1589
+ os.rename(
1590
+ mutation.path.name,
1591
+ quarantine_name,
1592
+ src_dir_fd=directory,
1593
+ dst_dir_fd=directory,
1594
+ )
1595
+ moved = _read_file_version_at(directory, quarantine_name)
1596
+ if moved != expected_current:
1597
+ if moved is not None:
1598
+ _restore_quarantined_file(
1599
+ directory,
1600
+ quarantine_name,
1601
+ mutation.path.name,
1602
+ moved,
1603
+ )
1604
+ quarantine_name = None
1605
+ raise RuntimeError(
1606
+ "Concurrent rollback replacement preserved; original retained at "
1607
+ f"{backup_path}"
1608
+ )
1609
+ elif _read_file_version_at(directory, mutation.path.name) is not None:
1610
+ raise RuntimeError(
1611
+ "Concurrent rollback create preserved; original retained at "
1612
+ f"{backup_path}"
1613
+ )
1614
+ try:
1615
+ os.link(
1616
+ backup_path.name,
1617
+ mutation.path.name,
1618
+ src_dir_fd=directory,
1619
+ dst_dir_fd=directory,
1620
+ follow_symlinks=False,
1621
+ )
1622
+ except FileExistsError as error:
1623
+ raise RuntimeError(
1624
+ "Concurrent rollback replacement preserved; original retained at "
1625
+ f"{backup_path}"
1626
+ ) from error
1627
+ if _read_file_version_at(directory, mutation.path.name) != mutation.before:
1628
+ raise RuntimeError(
1629
+ f"Plugin rollback identity was not restored: {mutation.path}"
1630
+ )
1631
+ if _read_file_version_at(directory, backup_path.name) != mutation.before:
1632
+ raise RuntimeError(f"Plugin rollback backup changed: {backup_path}")
1633
+ os.unlink(backup_path.name, dir_fd=directory)
1634
+ if quarantine_name is not None:
1635
+ if (
1636
+ _read_file_version_at(directory, quarantine_name)
1637
+ != expected_current
1638
+ ):
1639
+ raise RuntimeError(
1640
+ "Rollback quarantine changed; retained as "
1641
+ f"{mutation.path.parent / quarantine_name}"
1642
+ )
1643
+ os.unlink(quarantine_name, dir_fd=directory)
1644
+ quarantine_name = None
1645
+ os.fsync(directory)
1646
+ finally:
1647
+ os.close(directory)
1648
+ restored = _read_file_version(mutation.path)
1649
+ if restored != mutation.before:
1650
+ raise RuntimeError(
1651
+ f"Plugin rollback identity was not restored: {mutation.path}"
1652
+ )
1653
+ mutation.backup_path = None
1654
+
1655
+ @staticmethod
1656
+ def _discard_backup(mutation: FileMutation, conflicts: list[str]) -> None:
1657
+ backup_path = mutation.backup_path
1658
+ if backup_path is None or mutation.before is None:
1659
+ return
1660
+ try:
1661
+ backup = _read_file_version(backup_path)
1662
+ if backup != mutation.before:
1663
+ raise RuntimeError(f"Plugin rollback backup changed: {backup_path}")
1664
+ _secure_unlink(backup_path, backup, mutation.ancestors)
1665
+ mutation.backup_path = None
1666
+ except Exception as error:
1667
+ conflicts.append(f"{backup_path}: {error}")
1668
+
1669
+ @staticmethod
1670
+ def _matches_produced(
1671
+ mutation: FileMutation,
1672
+ current: FileVersion | None,
1673
+ ) -> bool:
1674
+ if mutation.produced_is_recorded:
1675
+ return any(current == produced for produced in mutation.produced_history)
1676
+ return False
1677
+
1678
+
1679
+ def _snapshot_files(paths: tuple[Path, ...]) -> PluginFileTransaction:
1680
+ """Preflight and pin one explicit plugin file transaction."""
1681
+ return PluginFileTransaction(paths)
1682
+
1683
+
1684
+ def _restore_file_snapshots(transaction: PluginFileTransaction) -> None:
1685
+ transaction.rollback()
1686
+
1687
+
1688
+ def _expected_replacement_mode(
1689
+ transaction: PluginFileTransaction,
1690
+ path: Path,
1691
+ *,
1692
+ default: int = 0o600,
1693
+ ) -> int:
1694
+ before = transaction.mutations[path].before
1695
+ return before.mode if before is not None else default
1696
+
1697
+
1698
+ def _expect_config_updates(
1699
+ transaction: PluginFileTransaction,
1700
+ updates: tuple[ConfigUpdate, ...] | list[ConfigUpdate],
1701
+ ) -> None:
1702
+ for update in updates:
1703
+ if update.content is None:
1704
+ transaction.expect_absent(update.path)
1705
+ else:
1706
+ transaction.expect_file(
1707
+ update.path,
1708
+ update.content,
1709
+ _expected_replacement_mode(transaction, update.path),
1710
+ )
1711
+
1712
+
1713
+ def _apply_config_updates_owned(
1714
+ transaction: PluginFileTransaction,
1715
+ updates: tuple[ConfigUpdate, ...] | list[ConfigUpdate],
1716
+ ) -> None:
1717
+ """Apply plugin config updates with pinned ancestors and exact snapshots."""
1718
+ pending = [
1719
+ update
1720
+ for update in updates
1721
+ if update.content is not None and update.content != update.original
1722
+ ]
1723
+ for update in pending:
1724
+ mutation = transaction.mutations[update.path]
1725
+ current = _read_file_version(update.path)
1726
+ current_content = current.content if current is not None else None
1727
+ if current != mutation.before or current_content != update.original:
1728
+ raise RuntimeError(
1729
+ f"Plugin config changed after preflight; refusing overwrite: {update.path}"
1730
+ )
1731
+ for update in pending:
1732
+ mutation = transaction.mutations[update.path]
1733
+ transaction.backup(update.path)
1734
+ version = _secure_atomic_write(
1735
+ update.path,
1736
+ update.content,
1737
+ mutation.expected_mode or 0o600,
1738
+ mutation.ancestors,
1739
+ mutation.before,
1740
+ )
1741
+ transaction.record_version(update.path, version)
1742
+
1743
+
1744
+ def _expect_rule_plan(
1745
+ transaction: PluginFileTransaction,
1746
+ plan: PluginRulePlan | None,
1747
+ ) -> None:
1748
+ if plan is None:
1749
+ return
1750
+ for update in plan.updates:
1751
+ if update.content is None:
1752
+ transaction.expect_absent(update.path)
1753
+ else:
1754
+ transaction.expect_file(
1755
+ update.path,
1756
+ update.content,
1757
+ _expected_replacement_mode(transaction, update.path),
1758
+ )
1759
+
1760
+
1761
+ def _apply_rule_plan_owned(
1762
+ transaction: PluginFileTransaction,
1763
+ plan: PluginRulePlan | None,
1764
+ ) -> None:
1765
+ """Apply native rule updates with exact CAS identity tracking."""
1766
+ if plan is None:
1767
+ return
1768
+ for update in plan.updates:
1769
+ mutation = transaction.mutations[update.path]
1770
+ current_content = (
1771
+ mutation.before.content if mutation.before is not None else None
1772
+ )
1773
+ if _read_file_version(update.path) != mutation.before:
1774
+ raise RuntimeError(f"Plugin rule changed after preflight: {update.path}")
1775
+ if current_content != update.original:
1776
+ raise RuntimeError(f"Plugin rule snapshot mismatch: {update.path}")
1777
+ for update in plan.updates:
1778
+ mutation = transaction.mutations[update.path]
1779
+ if update.content == update.original:
1780
+ transaction.record(update.path)
1781
+ continue
1782
+ transaction.backup(update.path)
1783
+ if update.content is None:
1784
+ if mutation.before is not None:
1785
+ _secure_unlink(update.path, mutation.before, mutation.ancestors)
1786
+ transaction.record(update.path)
1787
+ else:
1788
+ version = _secure_atomic_write(
1789
+ update.path,
1790
+ update.content,
1791
+ mutation.expected_mode or 0o600,
1792
+ mutation.ancestors,
1793
+ mutation.before,
1794
+ )
1795
+ transaction.record_version(update.path, version)
1796
+ for rule_name in plan.installed:
1797
+ print(f" Installed {rule_name} rule")
1798
+ for rule_name in plan.removed:
1799
+ print(f" Removed {rule_name} rule")
1800
+ for rule_name in plan.preserved:
1801
+ print(f" WARN preserved changed or user-owned plugin rule: {rule_name}")
1802
+
1803
+
1804
+ def _state_content(state: dict) -> bytes:
1805
+ return (json.dumps(state, indent=2) + "\n").encode("utf-8")
1806
+
1807
+
1808
+ def _write_plugin_state(
1809
+ state: dict,
1810
+ transaction: PluginFileTransaction | None,
1811
+ ) -> None:
1812
+ """Write state through the active lifecycle transaction when one exists."""
1813
+ if transaction is None:
1814
+ save_state(state)
1815
+ return
1816
+ mutation = transaction.mutations[PLUGINS_STATE_FILE]
1817
+ content = _state_content(state)
1818
+ transaction.backup(PLUGINS_STATE_FILE)
1819
+ version = _secure_atomic_write(
1820
+ PLUGINS_STATE_FILE,
1821
+ content,
1822
+ mutation.expected_mode or 0o600,
1823
+ mutation.ancestors,
1824
+ mutation.before,
1825
+ )
1826
+ transaction.record_version(PLUGINS_STATE_FILE, version)
1827
+
1828
+
1829
+ def _after_plugin_state_write(
1830
+ state: dict,
1831
+ editor: str,
1832
+ name: str,
1833
+ action: str,
1834
+ ) -> None:
1835
+ """Test seam after state identity is pinned inside the transaction."""
1836
+
1837
+
1838
+ def _prepare_asset_specs(
1839
+ name: str,
1840
+ pack_dir: Path,
1841
+ hook_specs: list[dict],
1842
+ ) -> tuple[AssetSpec, ...]:
1843
+ specs: list[AssetSpec] = []
1844
+ for hook in hook_specs:
1845
+ if hook["is_core"]:
1846
+ continue
1847
+ source = Path(hook["source"])
1848
+ version = _read_file_version(source)
1849
+ if version is None:
1850
+ raise RuntimeError(f"Plugin hook source disappeared: {source}")
1851
+ specs.append(
1852
+ AssetSpec(
1853
+ key=f"hook:{hook['name']}",
1854
+ path=HOOKS_DIR / f"plugin-{name}-{hook['name']}",
1855
+ content=version.content,
1856
+ mode=version.mode | 0o111,
1857
+ kind="hook",
1858
+ )
1859
+ )
1860
+ scripts_source = pack_dir / "scripts"
1861
+ if scripts_source.is_dir():
1862
+ for source in sorted(scripts_source.iterdir()):
1863
+ if source.name.startswith("__") or not source.is_file():
1864
+ continue
1865
+ version = _read_file_version(source)
1866
+ if version is None:
1867
+ raise RuntimeError(f"Plugin script source disappeared: {source}")
1868
+ mode = (
1869
+ version.mode | 0o111
1870
+ if source.suffix in (".py", ".sh")
1871
+ else version.mode
1872
+ )
1873
+ specs.append(
1874
+ AssetSpec(
1875
+ key=f"script:{source.name}",
1876
+ path=TOOLKIT_DATA_DIR / "plugin-scripts" / name / source.name,
1877
+ content=version.content,
1878
+ mode=mode,
1879
+ kind="script",
1880
+ )
1881
+ )
1882
+ return tuple(specs)
1883
+
1884
+
1885
+ def _asset_entries(ownership: object, name: str) -> dict[str, dict]:
1886
+ if not isinstance(ownership, dict):
1887
+ return {}
1888
+ if ownership.get("source") != f"ai-toolkit-plugin-{name}":
1889
+ return {}
1890
+ entries = ownership.get("entries")
1891
+ if not isinstance(entries, dict):
1892
+ return {}
1893
+ return {
1894
+ key: entry
1895
+ for key, entry in entries.items()
1896
+ if isinstance(key, str) and isinstance(entry, dict)
1897
+ }
1898
+
1899
+
1900
+ def _asset_entry_matches(path: Path, version: FileVersion, entry: object) -> bool:
1901
+ if not isinstance(entry, dict) or entry.get("path") != str(path):
1902
+ return False
1903
+ return (
1904
+ entry.get("sha256") == hashlib.sha256(version.content).hexdigest()
1905
+ and entry.get("mode") == version.mode
1906
+ and entry.get("device") == version.device
1907
+ and entry.get("inode") == version.inode
1908
+ )
1909
+
1910
+
1911
+ def _owned_asset_paths(
1912
+ state: dict,
1913
+ name: str,
1914
+ editor: str,
1915
+ retained_keys: frozenset[str] = frozenset(),
1916
+ ) -> tuple[Path, ...]:
1917
+ ownership = _shared_asset_ownership_for(state, name)
1918
+ entries = _asset_entries(ownership, name)
1919
+ consumers = _shared_asset_consumers(ownership, name)
1920
+ editor_keys = set(consumers.get(editor, ()))
1921
+ other_keys = {
1922
+ key
1923
+ for consumer, keys in consumers.items()
1924
+ if consumer != editor
1925
+ for key in keys
1926
+ }
1927
+ paths: list[Path] = []
1928
+ hook_prefix = f"plugin-{name}-"
1929
+ scripts_root = TOOLKIT_DATA_DIR / "plugin-scripts" / name
1930
+ for key, entry in entries.items():
1931
+ if key not in editor_keys or key in other_keys or key in retained_keys:
1932
+ continue
1933
+ raw_path = entry.get("path")
1934
+ kind = entry.get("kind")
1935
+ if not isinstance(raw_path, str):
1936
+ continue
1937
+ path = Path(raw_path).expanduser().absolute()
1938
+ if kind == "hook":
1939
+ if path.parent != HOOKS_DIR.absolute() or not path.name.startswith(
1940
+ hook_prefix
1941
+ ):
1942
+ raise RuntimeError(f"Unsafe owned plugin hook path: {path}")
1943
+ elif kind == "script":
1944
+ if path.parent != scripts_root.absolute():
1945
+ raise RuntimeError(f"Unsafe owned plugin script path: {path}")
1946
+ else:
1947
+ raise RuntimeError(f"Unknown plugin asset kind for {path}: {kind!r}")
1948
+ paths.append(path)
1949
+ return tuple(sorted(set(paths), key=str))
1950
+
1951
+
1952
+ def _preflight_asset_install(
1953
+ transaction: PluginFileTransaction,
1954
+ specs: tuple[AssetSpec, ...],
1955
+ previous_ownership: dict | None,
1956
+ name: str,
1957
+ *,
1958
+ allow_matching_adoption: bool = False,
1959
+ ) -> None:
1960
+ previous_entries = _asset_entries(previous_ownership, name)
1961
+ for spec in specs:
1962
+ before = transaction.mutations[spec.path].before
1963
+ if before is not None:
1964
+ is_owned = _asset_entry_matches(
1965
+ spec.path,
1966
+ before,
1967
+ previous_entries.get(spec.key),
1968
+ )
1969
+ is_adoptable = (
1970
+ allow_matching_adoption
1971
+ and before.content == spec.content
1972
+ and before.mode == spec.mode
1973
+ )
1974
+ if not is_owned and not is_adoptable:
1975
+ raise RuntimeError(
1976
+ f"Refusing user-owned plugin asset collision: {spec.path}"
1977
+ )
1978
+ transaction.expect_file(spec.path, spec.content, spec.mode)
1979
+
1980
+
1981
+ def _apply_asset_install(
1982
+ transaction: PluginFileTransaction,
1983
+ specs: tuple[AssetSpec, ...],
1984
+ name: str,
1985
+ editor: str,
1986
+ previous_ownership: dict | None,
1987
+ ) -> dict:
1988
+ produced_entries: dict[str, dict] = {}
1989
+ for spec in specs:
1990
+ mutation = transaction.mutations[spec.path]
1991
+ transaction.backup(spec.path)
1992
+ version = _secure_atomic_write(
1993
+ spec.path,
1994
+ spec.content,
1995
+ spec.mode,
1996
+ mutation.ancestors,
1997
+ mutation.before,
1998
+ )
1999
+ transaction.record_version(spec.path, version)
2000
+ produced_entries[spec.key] = {
2001
+ "path": str(spec.path),
2002
+ "kind": spec.kind,
2003
+ "sha256": hashlib.sha256(version.content).hexdigest(),
2004
+ "mode": version.mode,
2005
+ "device": version.device,
2006
+ "inode": version.inode,
2007
+ }
2008
+ print(f" Installed plugin {spec.kind}: {spec.path.name}")
2009
+ entries = _asset_entries(previous_ownership, name)
2010
+ entries.update(produced_entries)
2011
+ consumers = _shared_asset_consumers(previous_ownership, name)
2012
+ if produced_entries:
2013
+ consumers[editor] = sorted(produced_entries)
2014
+ else:
2015
+ consumers.pop(editor, None)
2016
+ retained_keys = {key for keys in consumers.values() for key in keys}
2017
+ entries = {key: entry for key, entry in entries.items() if key in retained_keys}
2018
+ return {
2019
+ "source": f"ai-toolkit-plugin-{name}",
2020
+ "entries": entries,
2021
+ "consumers": consumers,
2022
+ }
2023
+
2024
+
2025
+ def _preflight_asset_removal(
2026
+ transaction: PluginFileTransaction,
2027
+ ownership: dict | None,
2028
+ name: str,
2029
+ editor: str,
2030
+ retained_keys: frozenset[str] = frozenset(),
2031
+ ) -> AssetRemovalPlan:
2032
+ entries = _asset_entries(ownership, name)
2033
+ consumers = _shared_asset_consumers(ownership, name)
2034
+ editor_keys = set(consumers.get(editor, ()))
2035
+ other_keys = {
2036
+ key
2037
+ for consumer, keys in consumers.items()
2038
+ if consumer != editor
2039
+ for key in keys
2040
+ }
2041
+ removable: list[tuple[str, Path, FileVersion]] = []
2042
+ preserved: list[tuple[str, Path]] = []
2043
+ for key, entry in entries.items():
2044
+ if key not in editor_keys or key in other_keys or key in retained_keys:
2045
+ continue
2046
+ raw_path = entry.get("path")
2047
+ if not isinstance(raw_path, str):
2048
+ continue
2049
+ path = Path(raw_path).expanduser().absolute()
2050
+ mutation = transaction.mutations.get(path)
2051
+ if mutation is None:
2052
+ raise RuntimeError(f"Owned plugin asset was not preflighted: {path}")
2053
+ before = mutation.before
2054
+ if before is None:
2055
+ continue
2056
+ if _asset_entry_matches(path, before, entry):
2057
+ transaction.expect_absent(path)
2058
+ removable.append((key, path, before))
2059
+ else:
2060
+ preserved.append((key, path))
2061
+ return AssetRemovalPlan(tuple(removable), tuple(preserved))
2062
+
2063
+
2064
+ def _apply_asset_removal(
2065
+ transaction: PluginFileTransaction,
2066
+ plan: AssetRemovalPlan,
2067
+ ) -> None:
2068
+ for _key, path, before in plan.removable:
2069
+ mutation = transaction.mutations[path]
2070
+ transaction.backup(path)
2071
+ _secure_unlink(path, before, mutation.ancestors)
2072
+ transaction.record(path)
2073
+ print(f" Removed plugin asset: {path.name}")
2074
+ for _key, path in plan.preserved:
2075
+ print(f" WARN preserved changed or user-owned plugin asset: {path}")
2076
+
2077
+
2078
+ def _state_after_removal(
2079
+ state: dict,
2080
+ editor: str,
2081
+ name: str,
2082
+ ) -> dict:
2083
+ result = json.loads(json.dumps(state))
2084
+ _set_installed(
2085
+ result,
2086
+ editor,
2087
+ [
2088
+ installed
2089
+ for installed in _installed_for(result, editor)
2090
+ if installed != name
2091
+ ],
2092
+ )
2093
+ _forget_version(result, editor, name)
2094
+ _forget_mcp_ownership(result, editor, name)
2095
+ _forget_rule_ownership(result, editor, name)
2096
+ _release_shared_asset_consumer(result, name, editor)
2097
+ return result
2098
+
2099
+
2100
+ def _is_process_group_alive(process_group: int) -> bool:
2101
+ try:
2102
+ os.killpg(process_group, 0)
2103
+ except ProcessLookupError:
2104
+ return False
2105
+ except PermissionError: # pragma: no cover - same-user child groups are expected
2106
+ return True
2107
+ return True
2108
+
2109
+
2110
+ def _wait_for_init_tree_exit(
2111
+ process: subprocess.Popen[str],
2112
+ process_group: int,
2113
+ timeout: float,
2114
+ ) -> tuple[bool, KeyboardInterrupt | None]:
2115
+ deadline = time.monotonic() + timeout
2116
+ interrupted: KeyboardInterrupt | None = None
2117
+ while True:
2118
+ try:
2119
+ process.poll()
2120
+ except KeyboardInterrupt as error:
2121
+ interrupted = interrupted or error
2122
+ try:
2123
+ is_alive = _is_process_group_alive(process_group)
2124
+ except KeyboardInterrupt as error:
2125
+ interrupted = interrupted or error
2126
+ continue
2127
+ if not is_alive:
2128
+ return True, interrupted
2129
+ if time.monotonic() >= deadline:
2130
+ return False, interrupted
2131
+ try:
2132
+ time.sleep(0.02)
2133
+ except KeyboardInterrupt as error:
2134
+ interrupted = interrupted or error
2135
+
2136
+
2137
+ def _signal_plugin_init_group(
2138
+ process_group: int,
2139
+ signal_number: int,
2140
+ ) -> KeyboardInterrupt | None:
2141
+ """Deliver one group signal despite a bounded burst of user interrupts."""
2142
+ interrupted: KeyboardInterrupt | None = None
2143
+ for _attempt in range(5):
2144
+ try:
2145
+ os.killpg(process_group, signal_number)
2146
+ return interrupted
2147
+ except ProcessLookupError:
2148
+ return interrupted
2149
+ except KeyboardInterrupt as error:
2150
+ interrupted = interrupted or error
2151
+ return interrupted
2152
+
2153
+
2154
+ def _terminate_plugin_init_tree(
2155
+ process: subprocess.Popen[str],
2156
+ ) -> None:
2157
+ process_group = process.pid
2158
+ interrupted = _signal_plugin_init_group(process_group, signal.SIGTERM)
2159
+ exited, wait_interrupt = _wait_for_init_tree_exit(
2160
+ process,
2161
+ process_group,
2162
+ PLUGIN_INIT_TERMINATE_GRACE_SECONDS,
2163
+ )
2164
+ interrupted = interrupted or wait_interrupt
2165
+ if not exited:
2166
+ kill_interrupt = _signal_plugin_init_group(process_group, signal.SIGKILL)
2167
+ interrupted = interrupted or kill_interrupt
2168
+ exited, wait_interrupt = _wait_for_init_tree_exit(
2169
+ process,
2170
+ process_group,
2171
+ PLUGIN_INIT_KILL_GRACE_SECONDS,
2172
+ )
2173
+ interrupted = interrupted or wait_interrupt
2174
+ try:
2175
+ process.wait(timeout=PLUGIN_INIT_KILL_GRACE_SECONDS)
2176
+ except subprocess.TimeoutExpired as error:
2177
+ raise RuntimeError("Plugin init supervisor could not be reaped") from error
2178
+ except KeyboardInterrupt as error:
2179
+ interrupted = interrupted or error
2180
+ while process.poll() is None:
2181
+ try:
2182
+ process.wait(timeout=0.1)
2183
+ except subprocess.TimeoutExpired:
2184
+ continue
2185
+ except KeyboardInterrupt as repeated:
2186
+ interrupted = interrupted or repeated
2187
+ if not exited or _is_process_group_alive(process_group):
2188
+ raise RuntimeError("Plugin init process group exit could not be confirmed")
2189
+ if interrupted is not None:
2190
+ raise interrupted
2191
+
2192
+
2193
+ def _run_plugin_init_posix(
2194
+ init_script: Path,
2195
+ environment: dict[str, str],
2196
+ ) -> subprocess.CompletedProcess[str] | None:
2197
+ process = subprocess.Popen(
2198
+ ["python3", str(init_script)],
2199
+ stdout=subprocess.PIPE,
2200
+ stderr=subprocess.PIPE,
2201
+ text=True,
2202
+ env=environment,
2203
+ start_new_session=True,
2204
+ )
2205
+ try:
2206
+ stdout, stderr = process.communicate(timeout=PLUGIN_INIT_TIMEOUT_SECONDS)
2207
+ except subprocess.TimeoutExpired:
2208
+ _terminate_plugin_init_tree(process)
2209
+ print(f" WARN init timed out after {PLUGIN_INIT_TIMEOUT_SECONDS} seconds")
2210
+ return None
2211
+ except BaseException:
2212
+ _terminate_plugin_init_tree(process)
2213
+ raise
2214
+ return subprocess.CompletedProcess(
2215
+ process.args,
2216
+ process.returncode,
2217
+ stdout=stdout,
2218
+ stderr=stderr,
2219
+ )
2220
+
2221
+
2222
+ def _run_plugin_init(pack_dir: Path) -> None:
2223
+ scripts_source = pack_dir / "scripts"
2224
+ for candidate in ("init.py", "init_db.py"):
2225
+ init_script = scripts_source / candidate
2226
+ if not init_script.is_file() or init_script.is_symlink():
2227
+ continue
2228
+ if os.name != "posix":
2229
+ raise RuntimeError(
2230
+ "Plugin init process-tree isolation is unsupported on this platform"
2231
+ )
2232
+ environment = dict(os.environ)
2233
+ environment["AI_TOOLKIT_PLUGIN_INIT_ACTIVE"] = "1"
2234
+ result = _run_plugin_init_posix(init_script, environment)
2235
+ if result is None:
2236
+ break
2237
+ if result.returncode == 0 and result.stdout.strip():
2238
+ print(f" Init: {result.stdout.strip()}")
2239
+ elif result.returncode != 0:
2240
+ detail = result.stderr.strip() or result.stdout.strip() or "no output"
2241
+ print(f" WARN init failed: {detail}")
2242
+ break
2243
+
2244
+
2245
+ def _run_plugin_init_requests(pack_dirs: list[Path]) -> None:
2246
+ """Run successful install initializers after releasing the lifecycle lock."""
2247
+ if os.environ.get("AI_TOOLKIT_PLUGIN_INIT_ACTIVE") == "1":
2248
+ return
2249
+ for pack_dir in dict.fromkeys(pack_dirs):
2250
+ _run_plugin_init(pack_dir)
2251
+
2252
+
2253
+ def _plugin_install_transaction_paths(
2254
+ name: str,
2255
+ pack_dir: Path,
2256
+ editor: str,
2257
+ hook_specs: list[dict],
2258
+ mcp_plan: PluginMcpInstallPlan | None,
2259
+ rule_plan: PluginRulePlan | None,
2260
+ asset_specs: tuple[AssetSpec, ...],
2261
+ retiring_asset_paths: tuple[Path, ...] = (),
2262
+ ) -> tuple[Path, ...]:
2263
+ """Return every file a Cursor/Gemini install may mutate."""
2264
+ paths = {
2265
+ PLUGINS_STATE_FILE,
2266
+ JSON_HOOK_RUNTIMES[editor]["config"],
2267
+ }
2268
+ if mcp_plan is not None:
2269
+ paths.update(update.path for update in mcp_plan.updates)
2270
+ if rule_plan is not None:
2271
+ paths.update(update.path for update in rule_plan.updates)
2272
+ paths.update(spec.path for spec in asset_specs)
2273
+ paths.update(retiring_asset_paths)
2274
+ return tuple(sorted(paths, key=str))
2275
+
2276
+
2277
+ def _plugin_update_transaction_paths(
2278
+ name: str,
2279
+ pack_dir: Path,
2280
+ editor: str,
2281
+ hook_specs: list[dict],
2282
+ mcp_install_plan: PluginMcpInstallPlan | None,
2283
+ rule_install_plan: PluginRulePlan | None,
2284
+ mcp_removal_plan: PluginMcpRemovalPlan | None,
2285
+ rule_removal_plan: PluginRulePlan | None,
2286
+ asset_specs: tuple[AssetSpec, ...],
2287
+ owned_asset_paths: tuple[Path, ...],
2288
+ ) -> tuple[Path, ...]:
2289
+ """Return old and new files participating in one JSON-runtime update."""
2290
+ paths = set(
2291
+ _plugin_install_transaction_paths(
2292
+ name,
2293
+ pack_dir,
2294
+ editor,
2295
+ hook_specs,
2296
+ mcp_install_plan,
2297
+ rule_install_plan,
2298
+ asset_specs,
2299
+ (),
2300
+ )
2301
+ )
2302
+ if mcp_removal_plan is not None:
2303
+ paths.update(update.path for update in mcp_removal_plan.updates)
2304
+ if rule_removal_plan is not None:
2305
+ paths.update(update.path for update in rule_removal_plan.updates)
2306
+ paths.update(owned_asset_paths)
2307
+ return tuple(sorted(paths, key=str))
2308
+
2309
+
2310
+ def _plugin_remove_transaction_paths(
2311
+ name: str,
2312
+ editor: str,
2313
+ mcp_plan: PluginMcpRemovalPlan | None,
2314
+ rule_plan: PluginRulePlan | None,
2315
+ owned_asset_paths: tuple[Path, ...],
2316
+ ) -> tuple[Path, ...]:
2317
+ """Return every owned file a JSON-runtime removal may mutate."""
2318
+ paths = {
2319
+ PLUGINS_STATE_FILE,
2320
+ JSON_HOOK_RUNTIMES[editor]["config"],
2321
+ }
2322
+ if mcp_plan is not None:
2323
+ paths.update(update.path for update in mcp_plan.updates)
2324
+ if rule_plan is not None:
2325
+ paths.update(update.path for update in rule_plan.updates)
2326
+ paths.update(owned_asset_paths)
2327
+ return tuple(sorted(paths, key=str))
2328
+
2329
+
2330
+ def _rollback_plugin_transaction(
2331
+ snapshots: PluginFileTransaction | None,
2332
+ error: Exception,
2333
+ ) -> None:
2334
+ """Restore one failed plugin transaction or raise a combined error."""
2335
+ if snapshots is None:
2336
+ return
2337
+ try:
2338
+ _restore_file_snapshots(snapshots)
2339
+ except Exception as rollback_error:
2340
+ raise RuntimeError(
2341
+ f"Plugin transaction failed ({error}); rollback also failed: "
2342
+ f"{rollback_error}"
2343
+ ) from error
2344
+
2345
+
2346
+ def install_pack_json_runtime(
2347
+ runtime: str,
2348
+ name: str,
2349
+ *,
2350
+ mcp_planned: bool = False,
2351
+ native_rules_planned: bool = False,
2352
+ hooks_planned: bool = False,
2353
+ installed_asset_count: int = 0,
2354
+ ) -> bool:
2355
+ hooks_registered = hooks_planned
2356
+ if not hooks_registered and not mcp_planned and not native_rules_planned:
2357
+ print(
2358
+ f" WARN nothing registered for {runtime}; pack files are installed but inert"
2359
+ )
2360
+ print(f" Done: {name} for {runtime} ({installed_asset_count} file items)")
529
2361
  return True
530
2362
 
531
2363
 
532
2364
  def remove_pack_json_runtime(
533
- runtime: str, name: str, pack: dict, pack_dir: Path, *, keep_shared_assets: bool
2365
+ runtime: str,
2366
+ name: str,
534
2367
  ) -> bool:
535
- _strip_json_runtime_hooks(runtime, name)
536
- if not keep_shared_assets:
537
- for hook in HOOKS_DIR.glob(f"plugin-{name}-*"):
538
- hook.unlink()
539
- print(f" Removed hook: {hook.name}")
540
- scripts_dir = TOOLKIT_DATA_DIR / "plugin-scripts" / name
541
- if scripts_dir.is_dir():
542
- shutil.rmtree(scripts_dir)
543
- print(f" Removed scripts: {scripts_dir}")
544
2368
  print(f" Done: removed {name} from {runtime}")
545
2369
  return True
546
2370
 
@@ -582,7 +2406,7 @@ def _load_core_hook_matchers() -> dict[str, str]:
582
2406
  if not isinstance(hook, dict):
583
2407
  continue
584
2408
  command = hook.get("command", "")
585
- base = Path(command.replace("\"", "")).name
2409
+ base = Path(command.replace('"', "")).name
586
2410
  if base and base not in mapping:
587
2411
  mapping[base] = matcher
588
2412
  return mapping
@@ -595,6 +2419,7 @@ CORE_HOOK_MATCHERS = _load_core_hook_matchers()
595
2419
  # Claude runtime
596
2420
  # ---------------------------------------------------------------------------
597
2421
 
2422
+
598
2423
  def _ensure_claude_settings() -> Path:
599
2424
  CLAUDE_DIR.mkdir(parents=True, exist_ok=True)
600
2425
  settings_path = CLAUDE_DIR / "settings.json"
@@ -622,7 +2447,9 @@ def _clear_dangling_link(path: Path, label: str) -> bool:
622
2447
  return False
623
2448
 
624
2449
 
625
- def _install_claude_skills(pack: dict, pack_dir: Path, installed_items: list[str]) -> None:
2450
+ def _install_claude_skills(
2451
+ pack: dict, pack_dir: Path, installed_items: list[str]
2452
+ ) -> None:
626
2453
  for skill in pack.get("includes", {}).get("skills", []):
627
2454
  skill_dir = CLAUDE_DIR / "skills" / skill
628
2455
  source_dir = _resolve_skill_source(pack_dir, skill)
@@ -638,7 +2465,9 @@ def _install_claude_skills(pack: dict, pack_dir: Path, installed_items: list[str
638
2465
  print(f" WARN skill not found: {skill}")
639
2466
 
640
2467
 
641
- def _install_claude_agents(pack: dict, pack_dir: Path, installed_items: list[str]) -> None:
2468
+ def _install_claude_agents(
2469
+ pack: dict, pack_dir: Path, installed_items: list[str]
2470
+ ) -> None:
642
2471
  for agent in pack.get("includes", {}).get("agents", []):
643
2472
  agent_file = CLAUDE_DIR / "agents" / f"{agent}.md"
644
2473
  source_file = _resolve_agent_source(pack_dir, agent)
@@ -786,20 +2615,20 @@ def _remove_claude_pack_links(pack: dict, pack_dir: Path) -> None:
786
2615
  print(f" Removed agent link: {agent}")
787
2616
 
788
2617
 
789
- def remove_pack_claude(name: str, pack: dict, pack_dir: Path, *, keep_shared_assets: bool) -> bool:
2618
+ def remove_pack_claude(
2619
+ name: str, pack: dict, pack_dir: Path, *, keep_shared_assets: bool
2620
+ ) -> bool:
790
2621
  hook_specs = _resolve_pack_hooks(pack, pack_dir)
791
2622
  rule_specs = _resolve_pack_rules(pack, pack_dir)
792
2623
  _remove_claude_pack_links(pack, pack_dir)
793
2624
 
794
2625
  if not keep_shared_assets:
795
2626
  for hook in HOOKS_DIR.glob(f"plugin-{name}-*"):
796
- hook.unlink()
797
- print(f" Removed hook: {hook.name}")
2627
+ print(f" WARN preserved untracked plugin hook: {hook}")
798
2628
 
799
2629
  scripts_dir = TOOLKIT_DATA_DIR / "plugin-scripts" / name
800
2630
  if scripts_dir.is_dir():
801
- shutil.rmtree(scripts_dir)
802
- print(f" Removed scripts: {scripts_dir}")
2631
+ print(f" WARN preserved untracked plugin scripts: {scripts_dir}")
803
2632
 
804
2633
  if any(not spec["is_core"] for spec in hook_specs):
805
2634
  _strip_claude_hooks(name)
@@ -813,6 +2642,7 @@ def remove_pack_claude(name: str, pack: dict, pack_dir: Path, *, keep_shared_ass
813
2642
  # Codex runtime
814
2643
  # ---------------------------------------------------------------------------
815
2644
 
2645
+
816
2646
  def _install_all_codex_skills(target_root: Path) -> None:
817
2647
  skills_src = app_dir / "skills"
818
2648
  skills_dst = prepare_codex_skills_dir(target_root)
@@ -902,7 +2732,9 @@ def _codex_base_hook_present(data: dict, hook_name: str) -> bool:
902
2732
  for group in groups:
903
2733
  for handler in group.get("hooks", []):
904
2734
  command = handler.get("command", "")
905
- if not _codex_command_has_owner(command, TOOLKIT_COMMAND_MARKER.split("=", 1)[1]):
2735
+ if not _codex_command_has_owner(
2736
+ command, TOOLKIT_COMMAND_MARKER.split("=", 1)[1]
2737
+ ):
906
2738
  continue
907
2739
  if re.search(rf"/{re.escape(hook_name)}(?=[\"'\s]|$)", command):
908
2740
  return True
@@ -1005,7 +2837,9 @@ def _stage_codex_plugin_asset(destination: Path, content: bytes, mode: int) -> P
1005
2837
  raise
1006
2838
 
1007
2839
 
1008
- def _write_codex_plugin_asset(destination: Path, content: bytes, mode: int = 0o755) -> None:
2840
+ def _write_codex_plugin_asset(
2841
+ destination: Path, content: bytes, mode: int = 0o755
2842
+ ) -> None:
1009
2843
  staged = _stage_codex_plugin_asset(destination, content, mode)
1010
2844
  try:
1011
2845
  if destination.is_symlink():
@@ -1025,7 +2859,9 @@ def _prepare_codex_plugin_assets(name: str, specs: list[dict]) -> dict[Path, byt
1025
2859
  if destination.is_symlink():
1026
2860
  raise RuntimeError(f"Refusing symlinked Codex plugin hook: {destination}")
1027
2861
  if destination.exists() and not _is_owned_codex_plugin_asset(destination, name):
1028
- raise RuntimeError(f"Refusing user-owned Codex hook collision: {destination}")
2862
+ raise RuntimeError(
2863
+ f"Refusing user-owned Codex hook collision: {destination}"
2864
+ )
1029
2865
  assets[destination] = _codex_plugin_asset_content(name, spec)
1030
2866
  return assets
1031
2867
 
@@ -1045,7 +2881,7 @@ def _codex_plugin_command(name: str, spec: dict) -> str:
1045
2881
  owner = _codex_plugin_owner(name)
1046
2882
  asset_name = _codex_plugin_asset_name(name, spec)
1047
2883
  return (
1048
- f'AI_TOOLKIT_HOOK_OWNER={owner} '
2884
+ f"AI_TOOLKIT_HOOK_OWNER={owner} "
1049
2885
  f'"${{CODEX_HOME:-$HOME/.codex}}/ai-toolkit-hooks/{asset_name}"'
1050
2886
  )
1051
2887
 
@@ -1062,10 +2898,12 @@ def _install_codex_hooks(
1062
2898
  bucket = data.setdefault("hooks", {})
1063
2899
  for spec in specs:
1064
2900
  group = {
1065
- "hooks": [{
1066
- "type": "command",
1067
- "command": _codex_plugin_command(name, spec),
1068
- }]
2901
+ "hooks": [
2902
+ {
2903
+ "type": "command",
2904
+ "command": _codex_plugin_command(name, spec),
2905
+ }
2906
+ ]
1069
2907
  }
1070
2908
  matcher = _codex_matcher(spec)
1071
2909
  if matcher:
@@ -1120,7 +2958,9 @@ def _remove_codex_rules(name: str) -> None:
1120
2958
  if agents_md.is_file():
1121
2959
  content = agents_md.read_text(encoding="utf-8")
1122
2960
  changed = False
1123
- for match in re.findall(r"<!-- TOOLKIT:(plugin-" + re.escape(name) + r"-[^ ]+) START -->", content):
2961
+ for match in re.findall(
2962
+ r"<!-- TOOLKIT:(plugin-" + re.escape(name) + r"-[^ ]+) START -->", content
2963
+ ):
1124
2964
  content = strip_section(content, match)
1125
2965
  changed = True
1126
2966
  if changed:
@@ -1159,7 +2999,9 @@ def install_pack_codex(name: str, pack: dict, pack_dir: Path) -> bool:
1159
2999
  return True
1160
3000
 
1161
3001
 
1162
- def remove_pack_codex(name: str, pack: dict, pack_dir: Path, *, keep_shared_assets: bool) -> bool:
3002
+ def remove_pack_codex(
3003
+ name: str, pack: dict, pack_dir: Path, *, keep_shared_assets: bool
3004
+ ) -> bool:
1163
3005
  _assert_safe_codex_surface()
1164
3006
  _strip_codex_hooks(name)
1165
3007
 
@@ -1177,13 +3019,11 @@ def remove_pack_codex(name: str, pack: dict, pack_dir: Path, *, keep_shared_asse
1177
3019
  # Clean paths used by releases before native Codex plugin assets moved
1178
3020
  # under $CODEX_HOME. Claude still owns these when installed for both.
1179
3021
  for hook in HOOKS_DIR.glob(f"plugin-{name}-*"):
1180
- hook.unlink()
1181
- print(f" Removed hook: {hook.name}")
3022
+ print(f" WARN preserved untracked plugin hook: {hook}")
1182
3023
 
1183
3024
  scripts_dir = TOOLKIT_DATA_DIR / "plugin-scripts" / name
1184
3025
  if scripts_dir.is_dir():
1185
- shutil.rmtree(scripts_dir)
1186
- print(f" Removed scripts: {scripts_dir}")
3026
+ print(f" WARN preserved untracked plugin scripts: {scripts_dir}")
1187
3027
 
1188
3028
  _remove_codex_rules(name)
1189
3029
  print(f" Done: removed {name} from codex")
@@ -1194,7 +3034,28 @@ def remove_pack_codex(name: str, pack: dict, pack_dir: Path, *, keep_shared_asse
1194
3034
  # Common actions
1195
3035
  # ---------------------------------------------------------------------------
1196
3036
 
3037
+
1197
3038
  def install_pack(name: str, editor: str) -> bool:
3039
+ init_requests: list[Path] = []
3040
+ with _plugin_operation_gate():
3041
+ with _plugin_lifecycle_lock():
3042
+ installed = _install_pack_locked(
3043
+ name,
3044
+ editor,
3045
+ init_requests=init_requests,
3046
+ )
3047
+ if installed:
3048
+ _run_plugin_init_requests(init_requests)
3049
+ return installed
3050
+
3051
+
3052
+ def _install_pack_locked(
3053
+ name: str,
3054
+ editor: str,
3055
+ *,
3056
+ transaction_reports: list[PluginFileTransaction] | None = None,
3057
+ init_requests: list[Path] | None = None,
3058
+ ) -> bool:
1198
3059
  pack = find_pack(name)
1199
3060
  if not pack:
1200
3061
  print(f" ERROR: plugin pack '{name}' not found")
@@ -1214,34 +3075,201 @@ def install_pack(name: str, editor: str) -> bool:
1214
3075
  return False
1215
3076
 
1216
3077
  pack_dir = Path(pack["_dir"])
3078
+ if CODEX_PLUGIN_NAME_PATTERN.fullmatch(name) is None:
3079
+ raise ValueError(f"Unsafe plugin name: {name!r}")
1217
3080
  print(f" Installing: {name} for {editor} ({pack.get('description', '')})")
1218
3081
 
1219
- if editor == "claude":
1220
- ok = install_pack_claude(name, pack, pack_dir)
1221
- elif editor == "codex":
1222
- ok = install_pack_codex(name, pack, pack_dir)
1223
- elif editor in JSON_HOOK_RUNTIMES:
1224
- ok = install_pack_json_runtime(editor, name, pack, pack_dir)
1225
- else:
1226
- print(f" ERROR: no installer for runtime '{editor}'")
1227
- return False
1228
- if not ok:
1229
- return False
1230
-
1231
3082
  state = load_state()
1232
- installed = _installed_for(state, editor)
1233
- if name not in installed:
1234
- installed.append(name)
1235
- _set_installed(state, editor, installed)
1236
- # Recorded so `update --all` can skip a pack whose manifest has not moved.
1237
- # Without it, update removes and reinstalls every pack every time, which for
1238
- # a pack that downloads a binary means refetching it on every core update.
1239
- _record_version(state, editor, name, str(pack.get("version", "")))
1240
- save_state(state)
1241
- return True
3083
+ mcp_plan = prepare_plugin_mcp_install(
3084
+ name,
3085
+ editor,
3086
+ pack,
3087
+ pack_dir,
3088
+ _mcp_ownership_for(state, editor, name),
3089
+ )
3090
+ json_hook_specs = (
3091
+ _resolve_pack_hooks(pack, pack_dir) if editor in JSON_HOOK_RUNTIMES else []
3092
+ )
3093
+ json_rule_specs = (
3094
+ _resolve_pack_rules(pack, pack_dir) if editor in JSON_HOOK_RUNTIMES else []
3095
+ )
3096
+ asset_specs = (
3097
+ _prepare_asset_specs(name, pack_dir, json_hook_specs)
3098
+ if editor in JSON_HOOK_RUNTIMES
3099
+ else ()
3100
+ )
3101
+ retained_asset_keys = frozenset(spec.key for spec in asset_specs)
3102
+ retiring_asset_paths = (
3103
+ _owned_asset_paths(state, name, editor, retained_asset_keys)
3104
+ if editor in JSON_HOOK_RUNTIMES
3105
+ else ()
3106
+ )
3107
+ rule_plan = prepare_plugin_rule_install(
3108
+ name,
3109
+ editor,
3110
+ json_rule_specs,
3111
+ _rule_ownership_for(state, editor, name),
3112
+ )
3113
+ gemini_config_update = (
3114
+ _prepare_gemini_combined_install(name, json_hook_specs, mcp_plan)
3115
+ if editor == "gemini"
3116
+ else None
3117
+ )
3118
+ hook_config_update = (
3119
+ gemini_config_update
3120
+ if gemini_config_update is not None
3121
+ else (
3122
+ _prepare_json_runtime_hook_install(editor, name, json_hook_specs)
3123
+ if editor in JSON_HOOK_RUNTIMES
3124
+ else None
3125
+ )
3126
+ )
3127
+ transaction_snapshots = (
3128
+ _snapshot_files(
3129
+ _plugin_install_transaction_paths(
3130
+ name,
3131
+ pack_dir,
3132
+ editor,
3133
+ json_hook_specs,
3134
+ mcp_plan,
3135
+ rule_plan,
3136
+ asset_specs,
3137
+ retiring_asset_paths,
3138
+ )
3139
+ )
3140
+ if editor in JSON_HOOK_RUNTIMES
3141
+ else None
3142
+ )
3143
+ if transaction_snapshots is not None and transaction_reports is not None:
3144
+ transaction_reports.append(transaction_snapshots)
3145
+ asset_retirement_plan = (
3146
+ _preflight_asset_removal(
3147
+ transaction_snapshots,
3148
+ _shared_asset_ownership_for(state, name),
3149
+ name,
3150
+ editor,
3151
+ retained_asset_keys,
3152
+ )
3153
+ if transaction_snapshots is not None
3154
+ else AssetRemovalPlan((), ())
3155
+ )
3156
+ if transaction_snapshots is not None:
3157
+ _preflight_asset_install(
3158
+ transaction_snapshots,
3159
+ asset_specs,
3160
+ _shared_asset_ownership_for(state, name),
3161
+ name,
3162
+ allow_matching_adoption=any(
3163
+ name in _installed_for(state, other)
3164
+ for other in VALID_EDITORS
3165
+ if other != editor
3166
+ ),
3167
+ )
3168
+ if hook_config_update is not None:
3169
+ _expect_config_updates(transaction_snapshots, [hook_config_update])
3170
+ if gemini_config_update is None and mcp_plan is not None:
3171
+ _expect_config_updates(transaction_snapshots, mcp_plan.updates)
3172
+ _expect_rule_plan(transaction_snapshots, rule_plan)
3173
+
3174
+ try:
3175
+ asset_ownership: dict | None = None
3176
+ if transaction_snapshots is not None:
3177
+ _apply_asset_removal(transaction_snapshots, asset_retirement_plan)
3178
+ asset_ownership = _apply_asset_install(
3179
+ transaction_snapshots,
3180
+ asset_specs,
3181
+ name,
3182
+ editor,
3183
+ _shared_asset_ownership_for(state, name),
3184
+ )
3185
+ if editor == "claude":
3186
+ ok = install_pack_claude(name, pack, pack_dir)
3187
+ elif editor == "codex":
3188
+ ok = install_pack_codex(name, pack, pack_dir)
3189
+ elif editor in JSON_HOOK_RUNTIMES:
3190
+ ok = install_pack_json_runtime(
3191
+ editor,
3192
+ name,
3193
+ mcp_planned=mcp_plan is not None,
3194
+ native_rules_planned=rule_plan is not None,
3195
+ hooks_planned=hook_config_update is not None,
3196
+ installed_asset_count=len(asset_specs),
3197
+ )
3198
+ else:
3199
+ print(f" ERROR: no installer for runtime '{editor}'")
3200
+ return False
3201
+ if not ok:
3202
+ if transaction_snapshots is not None:
3203
+ _restore_file_snapshots(transaction_snapshots)
3204
+ return False
3205
+
3206
+ if hook_config_update is not None:
3207
+ if transaction_snapshots is not None:
3208
+ _apply_config_updates_owned(
3209
+ transaction_snapshots,
3210
+ [hook_config_update],
3211
+ )
3212
+ else: # pragma: no cover - hook plans are JSON-runtime only
3213
+ apply_config_updates([hook_config_update])
3214
+ print(f" Merged hooks into {hook_config_update.path}")
3215
+ if gemini_config_update is not None and mcp_plan is not None:
3216
+ _print_mcp_install_result(mcp_plan)
3217
+ else:
3218
+ if transaction_snapshots is not None and mcp_plan is not None:
3219
+ _apply_config_updates_owned(
3220
+ transaction_snapshots,
3221
+ mcp_plan.updates,
3222
+ )
3223
+ _print_mcp_install_result(mcp_plan)
3224
+ else:
3225
+ apply_plugin_mcp_install(mcp_plan)
3226
+ if transaction_snapshots is not None:
3227
+ _apply_rule_plan_owned(transaction_snapshots, rule_plan)
3228
+ installed = _installed_for(state, editor)
3229
+ if name not in installed:
3230
+ installed.append(name)
3231
+ _set_installed(state, editor, installed)
3232
+ # Recorded so update --all can skip an unchanged plugin manifest.
3233
+ _record_version(state, editor, name, str(pack.get("version", "")))
3234
+ if mcp_plan is not None:
3235
+ _record_mcp_ownership(state, editor, name, mcp_plan.ownership)
3236
+ if rule_plan is not None and rule_plan.ownership is not None:
3237
+ _record_rule_ownership(state, editor, name, rule_plan.ownership)
3238
+ if asset_ownership is not None:
3239
+ _record_shared_asset_ownership(state, name, asset_ownership)
3240
+ if transaction_snapshots is not None:
3241
+ transaction_snapshots.expect_file(
3242
+ PLUGINS_STATE_FILE,
3243
+ _state_content(state),
3244
+ _expected_replacement_mode(
3245
+ transaction_snapshots,
3246
+ PLUGINS_STATE_FILE,
3247
+ ),
3248
+ )
3249
+ _write_plugin_state(state, transaction_snapshots)
3250
+ if transaction_snapshots is not None:
3251
+ _after_plugin_state_write(state, editor, name, "install")
3252
+ transaction_snapshots.commit()
3253
+ if init_requests is not None:
3254
+ init_requests.append(pack_dir)
3255
+ return True
3256
+ except Exception as error:
3257
+ _rollback_plugin_transaction(transaction_snapshots, error)
3258
+ raise
1242
3259
 
1243
3260
 
1244
3261
  def remove_pack(name: str, editor: str) -> bool:
3262
+ with _plugin_operation_gate():
3263
+ with _plugin_lifecycle_lock():
3264
+ return _remove_pack_locked(name, editor)
3265
+
3266
+
3267
+ def _remove_pack_locked(
3268
+ name: str,
3269
+ editor: str,
3270
+ *,
3271
+ transaction_reports: list[PluginFileTransaction] | None = None,
3272
+ ) -> bool:
1245
3273
  state = load_state()
1246
3274
  installed = _installed_for(state, editor)
1247
3275
  if name not in installed:
@@ -1255,30 +3283,141 @@ def remove_pack(name: str, editor: str) -> bool:
1255
3283
 
1256
3284
  pack_dir = Path(pack["_dir"])
1257
3285
  print(f" Removing: {name} from {editor}")
3286
+ mcp_plan = prepare_plugin_mcp_removal(
3287
+ name,
3288
+ editor,
3289
+ _mcp_ownership_for(state, editor, name),
3290
+ )
3291
+ rule_plan = prepare_plugin_rule_removal(
3292
+ name,
3293
+ editor,
3294
+ _rule_ownership_for(state, editor, name),
3295
+ )
3296
+ gemini_config_update = (
3297
+ _prepare_gemini_combined_removal(name, mcp_plan) if editor == "gemini" else None
3298
+ )
3299
+ hook_config_update = (
3300
+ gemini_config_update
3301
+ if gemini_config_update is not None
3302
+ else (
3303
+ _prepare_json_runtime_hook_removal(editor, name)
3304
+ if editor in JSON_HOOK_RUNTIMES
3305
+ else None
3306
+ )
3307
+ )
1258
3308
  keep_shared_assets = any(
1259
3309
  name in _installed_for(state, other)
1260
3310
  for other in VALID_EDITORS
1261
3311
  if other != editor
1262
3312
  )
1263
- if editor == "claude":
1264
- ok = remove_pack_claude(name, pack, pack_dir, keep_shared_assets=keep_shared_assets)
1265
- elif editor == "codex":
1266
- ok = remove_pack_codex(name, pack, pack_dir, keep_shared_assets=keep_shared_assets)
1267
- elif editor in JSON_HOOK_RUNTIMES:
1268
- ok = remove_pack_json_runtime(
1269
- editor, name, pack, pack_dir, keep_shared_assets=keep_shared_assets
3313
+ owned_asset_paths = (
3314
+ _owned_asset_paths(state, name, editor) if editor in JSON_HOOK_RUNTIMES else ()
3315
+ )
3316
+ transaction_snapshots = (
3317
+ _snapshot_files(
3318
+ _plugin_remove_transaction_paths(
3319
+ name,
3320
+ editor,
3321
+ mcp_plan,
3322
+ rule_plan,
3323
+ owned_asset_paths,
3324
+ )
1270
3325
  )
1271
- else:
1272
- print(f" ERROR: no remover for runtime '{editor}'")
1273
- return False
1274
- if not ok:
1275
- return False
3326
+ if editor in JSON_HOOK_RUNTIMES
3327
+ else None
3328
+ )
3329
+ if transaction_snapshots is not None and transaction_reports is not None:
3330
+ transaction_reports.append(transaction_snapshots)
3331
+ asset_removal_plan = (
3332
+ _preflight_asset_removal(
3333
+ transaction_snapshots,
3334
+ _shared_asset_ownership_for(state, name),
3335
+ name,
3336
+ editor,
3337
+ )
3338
+ if transaction_snapshots is not None
3339
+ else AssetRemovalPlan((), ())
3340
+ )
3341
+ if transaction_snapshots is not None:
3342
+ if hook_config_update is not None:
3343
+ _expect_config_updates(transaction_snapshots, [hook_config_update])
3344
+ if gemini_config_update is None and mcp_plan is not None:
3345
+ _expect_config_updates(transaction_snapshots, mcp_plan.updates)
3346
+ _expect_rule_plan(transaction_snapshots, rule_plan)
3347
+ try:
3348
+ if hook_config_update is not None:
3349
+ if transaction_snapshots is not None:
3350
+ _apply_config_updates_owned(
3351
+ transaction_snapshots,
3352
+ [hook_config_update],
3353
+ )
3354
+ else: # pragma: no cover - hook plans are JSON-runtime only
3355
+ apply_config_updates([hook_config_update])
3356
+ print(f" Stripped hooks from {hook_config_update.path}")
3357
+ if gemini_config_update is not None and mcp_plan is not None:
3358
+ _print_mcp_removal_result(mcp_plan)
3359
+ if editor == "claude":
3360
+ ok = remove_pack_claude(
3361
+ name,
3362
+ pack,
3363
+ pack_dir,
3364
+ keep_shared_assets=keep_shared_assets,
3365
+ )
3366
+ elif editor == "codex":
3367
+ ok = remove_pack_codex(
3368
+ name,
3369
+ pack,
3370
+ pack_dir,
3371
+ keep_shared_assets=keep_shared_assets,
3372
+ )
3373
+ elif editor in JSON_HOOK_RUNTIMES:
3374
+ ok = remove_pack_json_runtime(
3375
+ editor,
3376
+ name,
3377
+ )
3378
+ else:
3379
+ print(f" ERROR: no remover for runtime '{editor}'")
3380
+ return False
3381
+ if not ok:
3382
+ if transaction_snapshots is not None:
3383
+ _restore_file_snapshots(transaction_snapshots)
3384
+ return False
1276
3385
 
1277
- installed = [p for p in installed if p != name]
1278
- _set_installed(state, editor, installed)
1279
- _forget_version(state, editor, name)
1280
- save_state(state)
1281
- return True
3386
+ if gemini_config_update is None:
3387
+ if transaction_snapshots is not None and mcp_plan is not None:
3388
+ _apply_config_updates_owned(
3389
+ transaction_snapshots,
3390
+ mcp_plan.updates,
3391
+ )
3392
+ _print_mcp_removal_result(mcp_plan)
3393
+ else:
3394
+ apply_plugin_mcp_removal(mcp_plan)
3395
+ if transaction_snapshots is not None:
3396
+ _apply_rule_plan_owned(transaction_snapshots, rule_plan)
3397
+ _apply_asset_removal(transaction_snapshots, asset_removal_plan)
3398
+ installed = [p for p in installed if p != name]
3399
+ _set_installed(state, editor, installed)
3400
+ _forget_version(state, editor, name)
3401
+ _forget_mcp_ownership(state, editor, name)
3402
+ _forget_rule_ownership(state, editor, name)
3403
+ _release_shared_asset_consumer(state, name, editor)
3404
+ if transaction_snapshots is not None:
3405
+ transaction_snapshots.expect_file(
3406
+ PLUGINS_STATE_FILE,
3407
+ _state_content(state),
3408
+ _expected_replacement_mode(
3409
+ transaction_snapshots,
3410
+ PLUGINS_STATE_FILE,
3411
+ ),
3412
+ )
3413
+ _write_plugin_state(state, transaction_snapshots)
3414
+ if transaction_snapshots is not None:
3415
+ _after_plugin_state_write(state, editor, name, "remove")
3416
+ transaction_snapshots.commit()
3417
+ return True
3418
+ except Exception as error:
3419
+ _rollback_plugin_transaction(transaction_snapshots, error)
3420
+ raise
1282
3421
 
1283
3422
 
1284
3423
  def pack_update_pending(name: str, editor: str) -> tuple[bool, str, str]:
@@ -1295,9 +3434,32 @@ def pack_update_pending(name: str, editor: str) -> tuple[bool, str, str]:
1295
3434
 
1296
3435
 
1297
3436
  def update_pack(name: str, editor: str, *, force: bool = False) -> bool:
3437
+ init_requests: list[Path] = []
3438
+ with _plugin_operation_gate():
3439
+ with _plugin_lifecycle_lock():
3440
+ updated = _update_pack_locked(
3441
+ name,
3442
+ editor,
3443
+ force=force,
3444
+ init_requests=init_requests,
3445
+ )
3446
+ if updated:
3447
+ _run_plugin_init_requests(init_requests)
3448
+ return updated
3449
+
3450
+
3451
+ def _update_pack_locked(
3452
+ name: str,
3453
+ editor: str,
3454
+ *,
3455
+ force: bool = False,
3456
+ init_requests: list[Path] | None = None,
3457
+ ) -> bool:
1298
3458
  state = load_state()
1299
3459
  if name not in _installed_for(state, editor):
1300
- print(f" Plugin '{name}' is not installed for {editor} — use 'install' instead")
3460
+ print(
3461
+ f" Plugin '{name}' is not installed for {editor} — use 'install' instead"
3462
+ )
1301
3463
  return False
1302
3464
 
1303
3465
  pending, current, available = pack_update_pending(name, editor)
@@ -1306,12 +3468,151 @@ def update_pack(name: str, editor: str, *, force: bool = False) -> bool:
1306
3468
  # installed pack on every invocation.
1307
3469
  return True
1308
3470
 
3471
+ pack = find_pack(name)
3472
+ if not pack:
3473
+ print(f" Plugin '{name}' manifest not found")
3474
+ return False
3475
+ pack_dir = Path(pack["_dir"])
3476
+ # Update is remove + install, so validate every MCP collision against the
3477
+ # current ownership record before remove_pack can touch state or assets.
3478
+ mcp_install_plan = prepare_plugin_mcp_install(
3479
+ name,
3480
+ editor,
3481
+ pack,
3482
+ pack_dir,
3483
+ _mcp_ownership_for(state, editor, name),
3484
+ )
3485
+ hook_specs: list[dict] = []
3486
+ rule_install_plan: PluginRulePlan | None = None
3487
+ mcp_removal_plan: PluginMcpRemovalPlan | None = None
3488
+ rule_removal_plan: PluginRulePlan | None = None
3489
+ if editor in JSON_HOOK_RUNTIMES:
3490
+ hook_specs = _resolve_pack_hooks(pack, pack_dir)
3491
+ rule_specs = _resolve_pack_rules(pack, pack_dir)
3492
+ rule_install_plan = prepare_plugin_rule_install(
3493
+ name,
3494
+ editor,
3495
+ rule_specs,
3496
+ _rule_ownership_for(state, editor, name),
3497
+ )
3498
+ mcp_removal_plan = prepare_plugin_mcp_removal(
3499
+ name,
3500
+ editor,
3501
+ _mcp_ownership_for(state, editor, name),
3502
+ )
3503
+ rule_removal_plan = prepare_plugin_rule_removal(
3504
+ name,
3505
+ editor,
3506
+ _rule_ownership_for(state, editor, name),
3507
+ )
3508
+ asset_specs = (
3509
+ _prepare_asset_specs(name, pack_dir, hook_specs)
3510
+ if editor in JSON_HOOK_RUNTIMES
3511
+ else ()
3512
+ )
3513
+ owned_asset_paths = (
3514
+ _owned_asset_paths(state, name, editor) if editor in JSON_HOOK_RUNTIMES else ()
3515
+ )
3516
+
1309
3517
  if current and available:
1310
3518
  print(f" Updating: {name} for {editor} ({current} -> {available})")
1311
3519
  else:
1312
3520
  print(f" Updating: {name} for {editor}")
1313
- remove_pack(name, editor)
1314
- return install_pack(name, editor)
3521
+ rollback_snapshots = (
3522
+ _snapshot_files(
3523
+ _plugin_update_transaction_paths(
3524
+ name,
3525
+ pack_dir,
3526
+ editor,
3527
+ hook_specs,
3528
+ mcp_install_plan,
3529
+ rule_install_plan,
3530
+ mcp_removal_plan,
3531
+ rule_removal_plan,
3532
+ asset_specs,
3533
+ owned_asset_paths,
3534
+ )
3535
+ )
3536
+ if editor in JSON_HOOK_RUNTIMES
3537
+ else None
3538
+ )
3539
+ outer_asset_removal = (
3540
+ _preflight_asset_removal(
3541
+ rollback_snapshots,
3542
+ _shared_asset_ownership_for(state, name),
3543
+ name,
3544
+ editor,
3545
+ )
3546
+ if rollback_snapshots is not None
3547
+ else AssetRemovalPlan((), ())
3548
+ )
3549
+ hook_removal_update: ConfigUpdate | None = None
3550
+ gemini_removal_update: ConfigUpdate | None = None
3551
+ if editor in JSON_HOOK_RUNTIMES:
3552
+ gemini_removal_update = (
3553
+ _prepare_gemini_combined_removal(name, mcp_removal_plan)
3554
+ if editor == "gemini"
3555
+ else None
3556
+ )
3557
+ hook_removal_update = (
3558
+ gemini_removal_update
3559
+ if gemini_removal_update is not None
3560
+ else _prepare_json_runtime_hook_removal(editor, name)
3561
+ )
3562
+ if rollback_snapshots is not None:
3563
+ if hook_removal_update is not None:
3564
+ _expect_config_updates(rollback_snapshots, [hook_removal_update])
3565
+ if gemini_removal_update is None and mcp_removal_plan is not None:
3566
+ _expect_config_updates(rollback_snapshots, mcp_removal_plan.updates)
3567
+ _expect_rule_plan(rollback_snapshots, rule_removal_plan)
3568
+ for _key, path, _before in outer_asset_removal.removable:
3569
+ rollback_snapshots.expect_absent(path)
3570
+ rollback_snapshots.backup(path)
3571
+ removal_state = _state_after_removal(
3572
+ state,
3573
+ editor,
3574
+ name,
3575
+ )
3576
+ rollback_snapshots.expect_file(
3577
+ PLUGINS_STATE_FILE,
3578
+ _state_content(removal_state),
3579
+ _expected_replacement_mode(
3580
+ rollback_snapshots,
3581
+ PLUGINS_STATE_FILE,
3582
+ ),
3583
+ )
3584
+ transaction_reports: list[PluginFileTransaction] = []
3585
+ try:
3586
+ if not _remove_pack_locked(
3587
+ name,
3588
+ editor,
3589
+ transaction_reports=transaction_reports,
3590
+ ):
3591
+ if rollback_snapshots is not None:
3592
+ _restore_file_snapshots(rollback_snapshots)
3593
+ return False
3594
+ if rollback_snapshots is not None:
3595
+ for report in transaction_reports:
3596
+ rollback_snapshots.accept_nested_states(report.final_exact_states())
3597
+ installed = _install_pack_locked(
3598
+ name,
3599
+ editor,
3600
+ transaction_reports=transaction_reports,
3601
+ init_requests=init_requests,
3602
+ )
3603
+ if not installed and rollback_snapshots is not None:
3604
+ for report in transaction_reports:
3605
+ rollback_snapshots.accept_nested_states(report.final_exact_states())
3606
+ _restore_file_snapshots(rollback_snapshots)
3607
+ elif installed and rollback_snapshots is not None:
3608
+ rollback_snapshots.commit()
3609
+ return installed
3610
+ except Exception as error:
3611
+ if rollback_snapshots is not None:
3612
+ for report in transaction_reports:
3613
+ rollback_snapshots.accept_nested_states(report.final_exact_states())
3614
+ _rollback_plugin_transaction(rollback_snapshots, error)
3615
+ raise
1315
3616
 
1316
3617
 
1317
3618
  # ---------------------------------------------------------------------------
@@ -1324,7 +3625,9 @@ CLEANABLE_PLUGINS = {"memory-pack"}
1324
3625
  def clean_pack(name: str, days: int = 90) -> bool:
1325
3626
  """Prune old data for a plugin. Returns True if successful."""
1326
3627
  state = load_state()
1327
- installed_anywhere = any(name in _installed_for(state, editor) for editor in VALID_EDITORS)
3628
+ installed_anywhere = any(
3629
+ name in _installed_for(state, editor) for editor in VALID_EDITORS
3630
+ )
1328
3631
  if not installed_anywhere:
1329
3632
  print(f" Plugin '{name}' is not installed")
1330
3633
  return False
@@ -1390,41 +3693,50 @@ def _human_size(size_bytes: int) -> str:
1390
3693
  # List / Status
1391
3694
  # ---------------------------------------------------------------------------
1392
3695
 
3696
+
1393
3697
  def cmd_list(editors: list[str]) -> None:
1394
3698
  packs = list_available()
1395
3699
  state = load_state()
1396
3700
 
1397
3701
  print("Available plugin packs:")
1398
3702
  print()
3703
+ runtime_header = "".join(
3704
+ f" {PLUGIN_EDITOR_LABELS[editor]:>7}" for editor in VALID_EDITORS
3705
+ )
1399
3706
  print(
1400
- f" {'Name':<20} {'Domain':<12} {'Status':<14} {'Agents':>7} {'Skills':>7} {'Hooks':>6}"
1401
- f" {'Claude':>6} {'Codex':>6}"
3707
+ f" {'Name':<20} {'Domain':<12} {'Status':<14} {'Agents':>7} "
3708
+ f"{'Skills':>7} {'Hooks':>6}{runtime_header}"
1402
3709
  )
3710
+ runtime_divider = "".join(f" {'-' * 7}" for _editor in VALID_EDITORS)
1403
3711
  print(
1404
- f" {'-'*20} {'-'*12} {'-'*14} {'-'*7} {'-'*7} {'-'*6}"
1405
- f" {'-'*6} {'-'*6}"
3712
+ f" {'-' * 20} {'-' * 12} {'-' * 14} {'-' * 7} "
3713
+ f"{'-' * 7} {'-' * 6}{runtime_divider}"
1406
3714
  )
1407
3715
 
1408
3716
  for pack in packs:
1409
3717
  inc = pack.get("includes", {})
1410
- claude = "YES" if pack["name"] in _installed_for(state, "claude") else ""
1411
- codex = "YES" if pack["name"] in _installed_for(state, "codex") else ""
3718
+ runtime_status = "".join(
3719
+ f" {('YES' if pack['name'] in _installed_for(state, editor) else ''):>7}"
3720
+ for editor in VALID_EDITORS
3721
+ )
1412
3722
  print(
1413
- f" {pack['name']:<20} {pack.get('domain',''):<12} {pack.get('status',''):<14}"
1414
- f" {len(inc.get('agents',[])):>7} {len(inc.get('skills',[])):>7} {len(inc.get('hooks',[])):>6}"
1415
- f" {claude:>6} {codex:>6}"
3723
+ f" {pack['name']:<20} {pack.get('domain', ''):<12} {pack.get('status', ''):<14}"
3724
+ f" {len(inc.get('agents', [])):>7} {len(inc.get('skills', [])):>7} {len(inc.get('hooks', [])):>6}"
3725
+ f"{runtime_status}"
1416
3726
  )
1417
3727
 
1418
3728
  print()
1419
- print(
1420
- f" Total: {len(packs)} packs | Claude: {len(_installed_for(state, 'claude'))}"
1421
- f" | Codex: {len(_installed_for(state, 'codex'))}"
3729
+ totals = " | ".join(
3730
+ f"{PLUGIN_EDITOR_LABELS[editor]}: {len(_installed_for(state, editor))}"
3731
+ for editor in VALID_EDITORS
1422
3732
  )
3733
+ print(f" Total: {len(packs)} packs | {totals}")
1423
3734
  print()
1424
- print(" Install: ai-toolkit plugin install --editor claude|codex|all <name>")
1425
- print(" Install all: ai-toolkit plugin install --editor claude|codex|all --all")
1426
- print(" Update: ai-toolkit plugin update --editor claude|codex|all <name>")
1427
- print(" Remove: ai-toolkit plugin remove --editor claude|codex|all <name>")
3735
+ editor_values = "|".join((*VALID_EDITORS, "all"))
3736
+ print(f" Install: ai-toolkit plugin install --editor {editor_values} <name>")
3737
+ print(f" Install all: ai-toolkit plugin install --editor {editor_values} --all")
3738
+ print(f" Update: ai-toolkit plugin update --editor {editor_values} <name>")
3739
+ print(f" Remove: ai-toolkit plugin remove --editor {editor_values} <name>")
1428
3740
  print(" Clean: ai-toolkit plugin clean <name> [--days N]")
1429
3741
 
1430
3742
 
@@ -1515,6 +3827,7 @@ def cmd_status(editors: list[str]) -> None:
1515
3827
  # CLI parsing
1516
3828
  # ---------------------------------------------------------------------------
1517
3829
 
3830
+
1518
3831
  def _parse_editors(args: list[str]) -> tuple[list[str], list[str]]:
1519
3832
  editors = ["claude"]
1520
3833
  remainder: list[str] = []
@@ -1541,7 +3854,7 @@ def _parse_editors(args: list[str]) -> tuple[list[str], list[str]]:
1541
3854
  invalid = [item for item in parsed if item not in VALID_EDITORS]
1542
3855
  if invalid:
1543
3856
  print(f"ERROR: unsupported editor(s): {', '.join(invalid)}")
1544
- print("Valid values: claude, codex, all")
3857
+ print(f"Valid values: {', '.join((*VALID_EDITORS, 'all'))}")
1545
3858
  sys.exit(1)
1546
3859
  editors = parsed or ["claude"]
1547
3860
  i += 1
@@ -1550,8 +3863,12 @@ def _parse_editors(args: list[str]) -> tuple[list[str], list[str]]:
1550
3863
 
1551
3864
  def _cmd_install(args: list[str], editors: list[str]) -> None:
1552
3865
  if not args:
1553
- print("Usage: ai-toolkit plugin install [--editor claude|codex|cursor|gemini|all] <pack-name> [...]")
1554
- print(" ai-toolkit plugin install [--editor claude|codex|cursor|gemini|all] --all")
3866
+ print(
3867
+ "Usage: ai-toolkit plugin install [--editor claude|codex|cursor|gemini|all] <pack-name> [...]"
3868
+ )
3869
+ print(
3870
+ " ai-toolkit plugin install [--editor claude|codex|cursor|gemini|all] --all"
3871
+ )
1555
3872
  sys.exit(1)
1556
3873
  names = [pack["name"] for pack in list_available()] if "--all" in args else args
1557
3874
  for editor in editors:
@@ -1569,8 +3886,12 @@ def _cmd_install(args: list[str], editors: list[str]) -> None:
1569
3886
 
1570
3887
  def _cmd_remove(args: list[str], editors: list[str]) -> None:
1571
3888
  if not args:
1572
- print("Usage: ai-toolkit plugin remove [--editor claude|codex|cursor|gemini|all] <pack-name> [...]")
1573
- print(" ai-toolkit plugin remove [--editor claude|codex|cursor|gemini|all] --all")
3889
+ print(
3890
+ "Usage: ai-toolkit plugin remove [--editor claude|codex|cursor|gemini|all] <pack-name> [...]"
3891
+ )
3892
+ print(
3893
+ " ai-toolkit plugin remove [--editor claude|codex|cursor|gemini|all] --all"
3894
+ )
1574
3895
  sys.exit(1)
1575
3896
  state = load_state()
1576
3897
  for editor in editors:
@@ -1586,8 +3907,12 @@ def _cmd_remove(args: list[str], editors: list[str]) -> None:
1586
3907
 
1587
3908
  def _cmd_update(args: list[str], editors: list[str]) -> None:
1588
3909
  if not args:
1589
- print("Usage: ai-toolkit plugin update [--editor claude|codex|cursor|gemini|all] <pack-name> [...]")
1590
- print(" ai-toolkit plugin update [--editor claude|codex|cursor|gemini|all] --all [--dry-run]")
3910
+ print(
3911
+ "Usage: ai-toolkit plugin update [--editor claude|codex|cursor|gemini|all] <pack-name> [...]"
3912
+ )
3913
+ print(
3914
+ " ai-toolkit plugin update [--editor claude|codex|cursor|gemini|all] --all [--dry-run]"
3915
+ )
1591
3916
  sys.exit(1)
1592
3917
 
1593
3918
  dry_run = "--dry-run" in args or "--list" in args
@@ -1596,6 +3921,7 @@ def _cmd_update(args: list[str], editors: list[str]) -> None:
1596
3921
  explicit = [a for a in args if not a.startswith("--")]
1597
3922
 
1598
3923
  state = load_state()
3924
+ had_failures = False
1599
3925
  for editor in editors:
1600
3926
  names = list(_installed_for(state, editor)) if everything else explicit
1601
3927
  if not names:
@@ -1612,7 +3938,9 @@ def _cmd_update(args: list[str], editors: list[str]) -> None:
1612
3938
  for name in names:
1613
3939
  needs, current, available = pack_update_pending(name, editor)
1614
3940
  if needs or force:
1615
- pending.append(f"{name} ({current or 'unrecorded'} -> {available or 'unknown'})")
3941
+ pending.append(
3942
+ f"{name} ({current or 'unrecorded'} -> {available or 'unknown'})"
3943
+ )
1616
3944
  if pending:
1617
3945
  print(f"Would update for {editor}: {', '.join(pending)}")
1618
3946
  else:
@@ -1628,16 +3956,20 @@ def _cmd_update(args: list[str], editors: list[str]) -> None:
1628
3956
  ok += 1
1629
3957
  else:
1630
3958
  failed.append(name)
3959
+ had_failures = True
1631
3960
  except Exception as exc: # noqa: BLE001
1632
3961
  # A pack failure must never abort the run: `ai-toolkit update`
1633
3962
  # calls this after the core update has already succeeded.
1634
3963
  print(f" WARN update failed for {name}: {exc}")
1635
3964
  failed.append(name)
3965
+ had_failures = True
1636
3966
  if everything and (failed or force):
1637
3967
  print(f"Updated: {ok}/{len(names)} packs for {editor}")
1638
3968
  if failed:
1639
3969
  print(f" Failed: {', '.join(failed)}")
1640
3970
  print()
3971
+ if had_failures:
3972
+ sys.exit(1)
1641
3973
 
1642
3974
 
1643
3975
  def _parse_clean_args(args: list[str]) -> tuple[list[str], int]: