@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.
- package/CHANGELOG.md +82 -0
- package/README.md +44 -18
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/ARCHITECTURE.md +2 -2
- package/app/mcp-templates/README.md +7 -2
- package/app/mcp-templates/rag-mcp-legal.json +11 -0
- package/app/mcp-templates/rag-mcp.json +11 -0
- package/app/surface.json +1 -0
- package/benchmarks/ecosystem-doctor-snapshot.json +29 -17
- package/bin/ai-toolkit.js +8 -0
- package/kb/history/completed/dsh-integration-plan-superseded.md +322 -0
- package/kb/history/completed/dsh-native-install-target-plan.md +331 -0
- package/kb/procedures/ecosystem-sync-sop.md +7 -5
- package/kb/procedures/maintenance-sop.md +1 -1
- package/kb/procedures/release-verification-sop.md +35 -5
- package/kb/reference/architecture-overview.md +24 -5
- package/kb/reference/cli-reference.md +1 -1
- package/kb/reference/dsh-compatibility.md +183 -0
- package/kb/reference/manifest-install.md +112 -5
- package/kb/reference/mcp-templates.md +11 -4
- package/kb/reference/plugin-pack-conventions.md +35 -18
- package/kb/reference/supported-tools-registry.md +30 -6
- package/llms-full.txt +1110 -50
- package/llms.txt +3 -0
- package/manifest.json +2 -2
- package/package.json +2 -2
- package/scripts/codex_skill_adapter.py +673 -34
- package/scripts/config_resolver.py +80 -14
- package/scripts/doctor.py +98 -20
- package/scripts/ecosystem_tools.json +51 -1
- package/scripts/generate_codex_skills.py +22 -20
- package/scripts/install.py +30 -13
- package/scripts/install_steps/ai_tools.py +97 -33
- package/scripts/install_steps/dsh.py +5063 -0
- package/scripts/install_steps/install_state.py +1645 -57
- package/scripts/mcp_editors.py +5 -2
- package/scripts/plugin.py +2495 -163
- package/scripts/plugin_mcp.py +279 -0
- package/scripts/plugin_rules.py +389 -0
- package/scripts/plugin_schema.py +139 -23
- package/scripts/uninstall.py +47 -4
- 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",
|
|
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",
|
|
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 {
|
|
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.
|
|
211
|
+
if PLUGINS_STATE_FILE.exists() or PLUGINS_STATE_FILE.is_symlink():
|
|
127
212
|
try:
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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) ->
|
|
265
|
+
def save_state(state: dict) -> FileVersion:
|
|
155
266
|
"""Save installed plugins state."""
|
|
156
|
-
PLUGINS_STATE_FILE
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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", {})[
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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(
|
|
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"
|
|
409
|
-
return f"
|
|
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
|
|
450
|
-
|
|
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: [
|
|
465
|
-
|
|
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(
|
|
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
|
-
|
|
489
|
-
print(f" Merged hooks into {config_path}")
|
|
490
|
-
return True
|
|
722
|
+
return landed
|
|
491
723
|
|
|
492
724
|
|
|
493
|
-
def
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
|
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
|
-
|
|
518
|
-
print(f" Stripped hooks from {config_path}")
|
|
748
|
+
return removed
|
|
519
749
|
|
|
520
750
|
|
|
521
|
-
def
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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,
|
|
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("
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
1067
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
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
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
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
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
if not
|
|
1275
|
-
|
|
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
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
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(
|
|
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
|
-
|
|
1314
|
-
|
|
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(
|
|
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}
|
|
1401
|
-
f"
|
|
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} {'-'*
|
|
1405
|
-
f"
|
|
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
|
-
|
|
1411
|
-
|
|
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"
|
|
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
|
-
|
|
1420
|
-
f"
|
|
1421
|
-
|
|
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
|
-
|
|
1425
|
-
print(" Install
|
|
1426
|
-
print("
|
|
1427
|
-
print("
|
|
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:
|
|
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(
|
|
1554
|
-
|
|
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(
|
|
1573
|
-
|
|
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(
|
|
1590
|
-
|
|
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(
|
|
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]:
|