@softspark/ai-toolkit 4.16.1 → 4.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +84 -0
- package/README.md +13 -19
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/hooks/session-end.sh +1 -13
- package/app/hooks.json +0 -10
- package/app/plugins/README.md +16 -4
- package/app/plugins/rtk-pack/README.md +123 -0
- package/app/plugins/rtk-pack/hooks/rewrite.sh +79 -0
- package/app/plugins/rtk-pack/plugin.json +60 -0
- package/app/plugins/rtk-pack/scripts/init.py +252 -0
- package/app/plugins/rtk-pack/scripts/status.py +105 -0
- package/benchmarks/ecosystem-doctor-snapshot.json +14 -15
- package/bin/ai-toolkit.js +15 -2
- package/kb/history/completed/output-filter-retirement-20260726.md +128 -0
- package/kb/history/completed/rtk-pack-integration-20260726.md +710 -0
- package/kb/procedures/maintenance-sop.md +1 -1
- package/kb/procedures/release-preparation-sop.md +8 -3
- package/kb/procedures/rtk-upstream-sync-sop.md +279 -0
- package/kb/reference/architecture-overview.md +2 -3
- package/kb/reference/cli-reference.md +4 -14
- package/kb/reference/enterprise-config-guide.md +1 -21
- package/kb/reference/hooks-catalog.md +3 -60
- package/kb/reference/plugin-pack-conventions.md +17 -2
- package/kb/reference/supported-tools-registry.md +0 -4
- package/llms-full.txt +1171 -402
- package/llms.txt +3 -1
- package/manifest.json +147 -36
- package/package.json +1 -2
- package/scripts/audit_skills.py +21 -0
- package/scripts/claude_app.py +2 -21
- package/scripts/config_cli.py +4 -0
- package/scripts/config_merger.py +0 -17
- package/scripts/config_validator.py +11 -138
- package/scripts/doctor.py +3 -20
- package/scripts/install.py +2 -1
- package/scripts/install_steps/ai_tools.py +28 -99
- package/scripts/install_steps/hooks.py +26 -24
- package/scripts/merge-hooks.py +33 -2
- package/scripts/output_filter_retirement.py +395 -0
- package/scripts/plugin.py +136 -16
- package/scripts/schemas/ai-toolkit-config.schema.json +0 -60
- package/scripts/uninstall.py +13 -27
- package/scripts/verify_rtk_binary.py +335 -0
- package/app/hooks/filter-tool-output.sh +0 -76
- package/app/output-filter-policy.json +0 -15
- package/benchmarks/output-filter/README.md +0 -11
- package/benchmarks/output-filter/scenarios.json +0 -25
- package/kb/reference/tool-output-filter.md +0 -288
- package/scripts/benchmark_output_filter.py +0 -343
- package/scripts/output_filter_cli.py +0 -347
- package/scripts/output_filter_hook.py +0 -23
- package/scripts/tool_output_filter/__init__.py +0 -33
- package/scripts/tool_output_filter/contracts.py +0 -173
- package/scripts/tool_output_filter/engine.py +0 -260
- package/scripts/tool_output_filter/hook_runtime.py +0 -369
- package/scripts/tool_output_filter/input.py +0 -56
- package/scripts/tool_output_filter/invariants.py +0 -40
- package/scripts/tool_output_filter/policy.py +0 -153
- package/scripts/tool_output_filter/profiles/__init__.py +0 -68
- package/scripts/tool_output_filter/profiles/repeat_lines.py +0 -71
- package/scripts/tool_output_filter/profiles/tap_success.py +0 -154
- package/scripts/tool_output_filter/recovery.py +0 -846
- package/scripts/tool_output_filter/telemetry.py +0 -13
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""Retirement cleanup for the v4.16.x native tool-output filter.
|
|
2
|
+
|
|
3
|
+
v4.16.0 and v4.16.1 installed a PostToolUse filter that wrote files outside the
|
|
4
|
+
npm package: a hook script, a global policy, per-project policy files, and
|
|
5
|
+
private recovery trees that can hold captured command output. v4.17.0 removed
|
|
6
|
+
the feature, so those files are orphaned on every machine that ran the old
|
|
7
|
+
releases. ``install``/``update`` and ``uninstall`` both reclaim them here.
|
|
8
|
+
|
|
9
|
+
The implementation is deliberately self-contained. The package that wrote these
|
|
10
|
+
files (``scripts/tool_output_filter/``) no longer exists in the repository, so
|
|
11
|
+
the ownership rules it enforced are re-stated as literal constants instead of
|
|
12
|
+
being imported. Nothing is removed without matching one of those rules, and no
|
|
13
|
+
path is ever opened through a symlink.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import stat
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Ownership constants copied from the removed v4.16.x runtime
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
HOOK_SCRIPT_NAME = "filter-tool-output.sh"
|
|
27
|
+
HOOK_SCRIPT_MARKER = b"# Claude PostToolUse adapter for the native tool-output filter."
|
|
28
|
+
|
|
29
|
+
GLOBAL_POLICY_NAME = "output-filter-policy.json"
|
|
30
|
+
|
|
31
|
+
PROJECT_POLICY_NAME = "ai-toolkit-output-filter.json"
|
|
32
|
+
PROJECT_OWNER_NAME = ".ai-toolkit-output-filter.owner"
|
|
33
|
+
PROJECT_OWNER_MARKER = b"ai-toolkit-output-filter-policy-v1\n"
|
|
34
|
+
|
|
35
|
+
RUNTIME_SCRIPTS: tuple[tuple[str, bytes], ...] = (
|
|
36
|
+
(
|
|
37
|
+
"output_filter_hook.py",
|
|
38
|
+
b'"""Lean process entry point for the Claude output-filter hook."""',
|
|
39
|
+
),
|
|
40
|
+
(
|
|
41
|
+
"output_filter_cli.py",
|
|
42
|
+
b'"""Manual and hook entry points for native tool-output filtering."""',
|
|
43
|
+
),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
RUNTIME_PACKAGE_NAME = "tool_output_filter"
|
|
47
|
+
RUNTIME_PACKAGE_MARKER = b'"""Dependency-free post-execution tool-output filtering."""'
|
|
48
|
+
_PACKAGE_MODULES: tuple[str, ...] = (
|
|
49
|
+
"__init__.py",
|
|
50
|
+
"contracts.py",
|
|
51
|
+
"engine.py",
|
|
52
|
+
"hook_runtime.py",
|
|
53
|
+
"input.py",
|
|
54
|
+
"invariants.py",
|
|
55
|
+
"policy.py",
|
|
56
|
+
"recovery.py",
|
|
57
|
+
"telemetry.py",
|
|
58
|
+
)
|
|
59
|
+
_PACKAGE_SUBPACKAGES: dict[str, tuple[str, ...]] = {
|
|
60
|
+
"profiles": ("__init__.py", "repeat_lines.py", "tap_success.py"),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
_RECOVERY_DIRECTORY_NAME = "output-filter"
|
|
64
|
+
_RECOVERY_TOKEN_LENGTH = 32
|
|
65
|
+
_RECOVERY_CIRCUIT_STATE_NAME = ".circuit-state.json"
|
|
66
|
+
_RECOVERY_TELEMETRY_NAME = ".telemetry.jsonl"
|
|
67
|
+
_RECOVERY_PENDING_PREFIX = ".pending-"
|
|
68
|
+
_RECOVERY_DIRECTORY_FLAGS = (
|
|
69
|
+
os.O_RDONLY
|
|
70
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
71
|
+
| getattr(os, "O_DIRECTORY", 0)
|
|
72
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# Recovery trees (sessions/<repo-key>/output-filter/<session>/)
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def _is_recovery_token(name: str) -> bool:
|
|
81
|
+
return (
|
|
82
|
+
len(name) == _RECOVERY_TOKEN_LENGTH
|
|
83
|
+
and all(character in "0123456789abcdef" for character in name)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _is_recovery_artifact_name(name: str) -> bool:
|
|
88
|
+
"""Match only the artifact names the removed filter could have written."""
|
|
89
|
+
if name in {_RECOVERY_CIRCUIT_STATE_NAME, _RECOVERY_TELEMETRY_NAME}:
|
|
90
|
+
return True
|
|
91
|
+
if name.endswith(".json") and _is_recovery_token(name[:-len(".json")]):
|
|
92
|
+
return True
|
|
93
|
+
return name.startswith(_RECOVERY_PENDING_PREFIX) and _is_recovery_token(
|
|
94
|
+
name[len(_RECOVERY_PENDING_PREFIX):]
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _assert_private_recovery_directory(directory_fd: int, label: str) -> None:
|
|
99
|
+
metadata = os.fstat(directory_fd)
|
|
100
|
+
if not stat.S_ISDIR(metadata.st_mode):
|
|
101
|
+
raise RuntimeError(f"{label} is not a directory")
|
|
102
|
+
if stat.S_IMODE(metadata.st_mode) & 0o077:
|
|
103
|
+
raise RuntimeError(f"{label} is not private")
|
|
104
|
+
if metadata.st_uid != os.getuid():
|
|
105
|
+
raise RuntimeError(f"{label} is not owned by this user")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _open_recovery_directory(parent_fd: int, name: str, label: str) -> int:
|
|
109
|
+
metadata = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
|
110
|
+
if not stat.S_ISDIR(metadata.st_mode):
|
|
111
|
+
raise RuntimeError(f"{label} is not a directory")
|
|
112
|
+
return os.open(name, _RECOVERY_DIRECTORY_FLAGS, dir_fd=parent_fd)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _scan_recovery_session(session_fd: int) -> tuple[str, ...]:
|
|
116
|
+
"""Return validated artifact names, refusing anything not owner-private."""
|
|
117
|
+
_assert_private_recovery_directory(session_fd, "owned recovery session")
|
|
118
|
+
artifacts = tuple(
|
|
119
|
+
name for name in os.listdir(session_fd) if _is_recovery_artifact_name(name)
|
|
120
|
+
)
|
|
121
|
+
for artifact_name in artifacts:
|
|
122
|
+
metadata = os.stat(artifact_name, dir_fd=session_fd, follow_symlinks=False)
|
|
123
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
124
|
+
raise RuntimeError("owned recovery artifact is not regular")
|
|
125
|
+
if stat.S_IMODE(metadata.st_mode) != 0o600:
|
|
126
|
+
raise RuntimeError("owned recovery artifact is not private")
|
|
127
|
+
return artifacts
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _walk_recovery_tree(sessions_root: Path, *, delete: bool) -> int:
|
|
131
|
+
"""Validate every repo tree, optionally unlinking the owned artifacts.
|
|
132
|
+
|
|
133
|
+
Counting validates the whole tree before ``delete`` ever runs, so an unsafe
|
|
134
|
+
symlink anywhere aborts the cleanup before the first unlink.
|
|
135
|
+
"""
|
|
136
|
+
try:
|
|
137
|
+
root_fd = os.open(sessions_root, _RECOVERY_DIRECTORY_FLAGS)
|
|
138
|
+
except FileNotFoundError:
|
|
139
|
+
return 0
|
|
140
|
+
except OSError as error:
|
|
141
|
+
raise RuntimeError(f"recovery base is unavailable: {error}") from error
|
|
142
|
+
total = 0
|
|
143
|
+
try:
|
|
144
|
+
for repo_name in sorted(os.listdir(root_fd)):
|
|
145
|
+
try:
|
|
146
|
+
metadata = os.stat(repo_name, dir_fd=root_fd, follow_symlinks=False)
|
|
147
|
+
except FileNotFoundError:
|
|
148
|
+
continue
|
|
149
|
+
if not stat.S_ISDIR(metadata.st_mode):
|
|
150
|
+
continue
|
|
151
|
+
repo_fd = os.open(repo_name, _RECOVERY_DIRECTORY_FLAGS, dir_fd=root_fd)
|
|
152
|
+
try:
|
|
153
|
+
total += _walk_recovery_repo(repo_fd, delete=delete)
|
|
154
|
+
finally:
|
|
155
|
+
os.close(repo_fd)
|
|
156
|
+
finally:
|
|
157
|
+
os.close(root_fd)
|
|
158
|
+
return total
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _walk_recovery_repo(repo_fd: int, *, delete: bool) -> int:
|
|
162
|
+
try:
|
|
163
|
+
output_fd = _open_recovery_directory(
|
|
164
|
+
repo_fd,
|
|
165
|
+
_RECOVERY_DIRECTORY_NAME,
|
|
166
|
+
"owned output-filter directory",
|
|
167
|
+
)
|
|
168
|
+
except FileNotFoundError:
|
|
169
|
+
return 0
|
|
170
|
+
total = 0
|
|
171
|
+
try:
|
|
172
|
+
_assert_private_recovery_directory(
|
|
173
|
+
output_fd,
|
|
174
|
+
"owned output-filter directory",
|
|
175
|
+
)
|
|
176
|
+
for session_name in sorted(os.listdir(output_fd)):
|
|
177
|
+
if not _is_recovery_token(session_name):
|
|
178
|
+
continue
|
|
179
|
+
session_fd = _open_recovery_directory(
|
|
180
|
+
output_fd,
|
|
181
|
+
session_name,
|
|
182
|
+
"owned recovery session",
|
|
183
|
+
)
|
|
184
|
+
try:
|
|
185
|
+
artifacts = _scan_recovery_session(session_fd)
|
|
186
|
+
total += len(artifacts)
|
|
187
|
+
if not delete:
|
|
188
|
+
continue
|
|
189
|
+
for artifact_name in artifacts:
|
|
190
|
+
os.unlink(artifact_name, dir_fd=session_fd)
|
|
191
|
+
if artifacts:
|
|
192
|
+
os.fsync(session_fd)
|
|
193
|
+
finally:
|
|
194
|
+
os.close(session_fd)
|
|
195
|
+
if delete:
|
|
196
|
+
# Foreign content keeps the directory: rmdir fails, we move on.
|
|
197
|
+
try:
|
|
198
|
+
os.rmdir(session_name, dir_fd=output_fd)
|
|
199
|
+
except OSError:
|
|
200
|
+
pass
|
|
201
|
+
finally:
|
|
202
|
+
os.close(output_fd)
|
|
203
|
+
if delete:
|
|
204
|
+
try:
|
|
205
|
+
os.rmdir(_RECOVERY_DIRECTORY_NAME, dir_fd=repo_fd)
|
|
206
|
+
except OSError:
|
|
207
|
+
pass
|
|
208
|
+
return total
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def count_owned_recovery_artifacts(sessions_root: Path) -> int:
|
|
212
|
+
"""Count validated leftover filter artifacts without mutating the tree."""
|
|
213
|
+
return _walk_recovery_tree(sessions_root, delete=False)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def clean_owned_recovery_tree(sessions_root: Path) -> int:
|
|
217
|
+
"""Delete validated leftover filter artifacts below all repo sessions."""
|
|
218
|
+
return _walk_recovery_tree(sessions_root, delete=True)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
# Per-project policy pair (<project>/.claude/)
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
def managed_project_policy(claude_dir: Path) -> list[Path]:
|
|
226
|
+
"""Return the managed per-project policy pair, or [] when unmanaged.
|
|
227
|
+
|
|
228
|
+
The owner marker is the ownership test: without it the policy file was
|
|
229
|
+
hand-written by the user and stays put.
|
|
230
|
+
"""
|
|
231
|
+
if claude_dir.is_symlink():
|
|
232
|
+
return []
|
|
233
|
+
policy = claude_dir / PROJECT_POLICY_NAME
|
|
234
|
+
owner = claude_dir / PROJECT_OWNER_NAME
|
|
235
|
+
if owner.is_symlink() or not owner.is_file():
|
|
236
|
+
return []
|
|
237
|
+
try:
|
|
238
|
+
if owner.read_bytes() != PROJECT_OWNER_MARKER:
|
|
239
|
+
return []
|
|
240
|
+
except OSError:
|
|
241
|
+
return []
|
|
242
|
+
managed = [owner]
|
|
243
|
+
if not policy.is_symlink() and policy.is_file():
|
|
244
|
+
managed.insert(0, policy)
|
|
245
|
+
return managed
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
# Global artifacts (~/.softspark/ai-toolkit/)
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
def _owned_regular_file(path: Path, marker: bytes | None) -> bool:
|
|
253
|
+
"""True only for a real file that still carries its shipped marker."""
|
|
254
|
+
if path.is_symlink() or not path.is_file():
|
|
255
|
+
return False
|
|
256
|
+
if marker is None:
|
|
257
|
+
return True
|
|
258
|
+
try:
|
|
259
|
+
return marker in path.read_bytes()
|
|
260
|
+
except OSError:
|
|
261
|
+
return False
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _owned_global_policy(path: Path) -> bool:
|
|
265
|
+
"""True for the seeded global policy, including a user-edited mode."""
|
|
266
|
+
if path.is_symlink() or not path.is_file():
|
|
267
|
+
return False
|
|
268
|
+
try:
|
|
269
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
270
|
+
except (OSError, ValueError):
|
|
271
|
+
return False
|
|
272
|
+
return isinstance(document, dict) and "mode" in document
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _owned_package_root(package_root: Path) -> bool:
|
|
276
|
+
if package_root.is_symlink() or not package_root.is_dir():
|
|
277
|
+
return False
|
|
278
|
+
return _owned_regular_file(package_root / "__init__.py", RUNTIME_PACKAGE_MARKER)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _remove_package_members(directory: Path, modules: tuple[str, ...]) -> None:
|
|
282
|
+
"""Unlink known modules plus their bytecode, leaving anything else alone."""
|
|
283
|
+
stems = {name[: -len(".py")] for name in modules}
|
|
284
|
+
for name in modules:
|
|
285
|
+
member = directory / name
|
|
286
|
+
if not member.is_symlink() and member.is_file():
|
|
287
|
+
member.unlink()
|
|
288
|
+
cache = directory / "__pycache__"
|
|
289
|
+
if not cache.is_symlink() and cache.is_dir():
|
|
290
|
+
for compiled in sorted(cache.iterdir()):
|
|
291
|
+
if compiled.is_symlink() or not compiled.is_file():
|
|
292
|
+
continue
|
|
293
|
+
if not compiled.name.endswith(".pyc") or ".cpython-" not in compiled.name:
|
|
294
|
+
continue
|
|
295
|
+
if compiled.name.split(".", 1)[0] in stems:
|
|
296
|
+
compiled.unlink()
|
|
297
|
+
_rmdir_if_empty(cache)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _rmdir_if_empty(directory: Path) -> None:
|
|
301
|
+
try:
|
|
302
|
+
directory.rmdir()
|
|
303
|
+
except OSError:
|
|
304
|
+
# Foreign content still lives here: leave the whole directory in place.
|
|
305
|
+
pass
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _global_artifact_paths(toolkit_data_dir: Path) -> list[tuple[Path, str]]:
|
|
309
|
+
"""Return every verified global leftover as (path, human label)."""
|
|
310
|
+
hooks_dir = toolkit_data_dir / "hooks"
|
|
311
|
+
scripts_dir = toolkit_data_dir / "scripts"
|
|
312
|
+
found: list[tuple[Path, str]] = []
|
|
313
|
+
|
|
314
|
+
if not hooks_dir.is_symlink() and hooks_dir.is_dir():
|
|
315
|
+
hook_script = hooks_dir / HOOK_SCRIPT_NAME
|
|
316
|
+
if _owned_regular_file(hook_script, HOOK_SCRIPT_MARKER):
|
|
317
|
+
found.append((hook_script, f"hooks/{HOOK_SCRIPT_NAME}"))
|
|
318
|
+
global_policy = hooks_dir / GLOBAL_POLICY_NAME
|
|
319
|
+
if _owned_global_policy(global_policy):
|
|
320
|
+
found.append((global_policy, f"hooks/{GLOBAL_POLICY_NAME}"))
|
|
321
|
+
|
|
322
|
+
if not scripts_dir.is_symlink() and scripts_dir.is_dir():
|
|
323
|
+
for name, marker in RUNTIME_SCRIPTS:
|
|
324
|
+
runtime_script = scripts_dir / name
|
|
325
|
+
if _owned_regular_file(runtime_script, marker):
|
|
326
|
+
found.append((runtime_script, f"scripts/{name}"))
|
|
327
|
+
package_root = scripts_dir / RUNTIME_PACKAGE_NAME
|
|
328
|
+
if _owned_package_root(package_root):
|
|
329
|
+
found.append((package_root, f"scripts/{RUNTIME_PACKAGE_NAME}/"))
|
|
330
|
+
|
|
331
|
+
return found
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _recovery_label(artifacts: int) -> str:
|
|
335
|
+
noun = "file" if artifacts == 1 else "files"
|
|
336
|
+
return f"sessions/*/{_RECOVERY_DIRECTORY_NAME}/ ({artifacts} {noun})"
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def find_global_artifacts(toolkit_data_dir: Path) -> list[str]:
|
|
340
|
+
"""List labels for leftovers a cleanup run would remove. Never mutates."""
|
|
341
|
+
labels = [label for _, label in _global_artifact_paths(toolkit_data_dir)]
|
|
342
|
+
try:
|
|
343
|
+
artifacts = count_owned_recovery_artifacts(toolkit_data_dir / "sessions")
|
|
344
|
+
except (OSError, RuntimeError):
|
|
345
|
+
artifacts = 0
|
|
346
|
+
if artifacts:
|
|
347
|
+
labels.append(_recovery_label(artifacts))
|
|
348
|
+
return labels
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def cleanup_global_artifacts(toolkit_data_dir: Path) -> tuple[list[str], list[str]]:
|
|
352
|
+
"""Remove verified leftovers under the toolkit data directory.
|
|
353
|
+
|
|
354
|
+
Returns ``(removed_labels, warnings)``. Both are empty on a clean machine,
|
|
355
|
+
which keeps the caller silent when there is nothing to retire.
|
|
356
|
+
"""
|
|
357
|
+
removed: list[str] = []
|
|
358
|
+
warnings: list[str] = []
|
|
359
|
+
|
|
360
|
+
for path, label in _global_artifact_paths(toolkit_data_dir):
|
|
361
|
+
try:
|
|
362
|
+
if path.is_dir():
|
|
363
|
+
_remove_package_members(path, _PACKAGE_MODULES)
|
|
364
|
+
for sub_name, sub_modules in _PACKAGE_SUBPACKAGES.items():
|
|
365
|
+
sub_directory = path / sub_name
|
|
366
|
+
if sub_directory.is_symlink() or not sub_directory.is_dir():
|
|
367
|
+
continue
|
|
368
|
+
_remove_package_members(sub_directory, sub_modules)
|
|
369
|
+
_rmdir_if_empty(sub_directory)
|
|
370
|
+
_rmdir_if_empty(path)
|
|
371
|
+
if path.exists():
|
|
372
|
+
warnings.append(
|
|
373
|
+
f"removed {label} modules but kept the directory "
|
|
374
|
+
"(unrecognized files inside)"
|
|
375
|
+
)
|
|
376
|
+
continue
|
|
377
|
+
else:
|
|
378
|
+
path.unlink()
|
|
379
|
+
except OSError as error:
|
|
380
|
+
warnings.append(f"kept {label} ({error})")
|
|
381
|
+
continue
|
|
382
|
+
removed.append(label)
|
|
383
|
+
|
|
384
|
+
sessions_root = toolkit_data_dir / "sessions"
|
|
385
|
+
try:
|
|
386
|
+
artifacts = clean_owned_recovery_tree(sessions_root)
|
|
387
|
+
except (OSError, RuntimeError) as error:
|
|
388
|
+
warnings.append(
|
|
389
|
+
f"kept sessions/*/{_RECOVERY_DIRECTORY_NAME}/ recovery data ({error})"
|
|
390
|
+
)
|
|
391
|
+
else:
|
|
392
|
+
if artifacts:
|
|
393
|
+
removed.append(_recovery_label(artifacts))
|
|
394
|
+
|
|
395
|
+
return removed, warnings
|
package/scripts/plugin.py
CHANGED
|
@@ -76,8 +76,8 @@ CODEX_PLUGIN_ASSET_MARKER = "# ai-toolkit-managed: codex-plugin-hook"
|
|
|
76
76
|
def _empty_state() -> dict:
|
|
77
77
|
return {
|
|
78
78
|
"targets": {
|
|
79
|
-
"claude": {"installed": []},
|
|
80
|
-
"codex": {"installed": []},
|
|
79
|
+
"claude": {"installed": [], "versions": {}},
|
|
80
|
+
"codex": {"installed": [], "versions": {}},
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
@@ -103,6 +103,13 @@ def load_state() -> dict:
|
|
|
103
103
|
installed = targets.get(editor, {}).get("installed", [])
|
|
104
104
|
if isinstance(installed, list):
|
|
105
105
|
state["targets"][editor]["installed"] = sorted(set(installed))
|
|
106
|
+
# Absent in state written before versions were tracked, so
|
|
107
|
+
# every pack looks stale once and is updated exactly once.
|
|
108
|
+
versions = targets.get(editor, {}).get("versions", {})
|
|
109
|
+
if isinstance(versions, dict):
|
|
110
|
+
state["targets"][editor]["versions"] = {
|
|
111
|
+
k: v for k, v in versions.items() if isinstance(v, str)
|
|
112
|
+
}
|
|
106
113
|
return state
|
|
107
114
|
|
|
108
115
|
|
|
@@ -118,6 +125,18 @@ def _installed_for(state: dict, editor: str) -> list[str]:
|
|
|
118
125
|
return list(state.get("targets", {}).get(editor, {}).get("installed", []))
|
|
119
126
|
|
|
120
127
|
|
|
128
|
+
def _installed_version(state: dict, editor: str, name: str) -> str:
|
|
129
|
+
return state.get("targets", {}).get(editor, {}).get("versions", {}).get(name, "")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _record_version(state: dict, editor: str, name: str, version: str) -> None:
|
|
133
|
+
state.setdefault("targets", {}).setdefault(editor, {}).setdefault("versions", {})[name] = version
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _forget_version(state: dict, editor: str, name: str) -> None:
|
|
137
|
+
state.get("targets", {}).get(editor, {}).get("versions", {}).pop(name, None)
|
|
138
|
+
|
|
139
|
+
|
|
121
140
|
def _set_installed(state: dict, editor: str, names: list[str]) -> None:
|
|
122
141
|
state.setdefault("targets", {}).setdefault(editor, {})
|
|
123
142
|
state["targets"][editor]["installed"] = sorted(set(names))
|
|
@@ -264,6 +283,11 @@ def _copy_plugin_scripts(name: str, pack_dir: Path, installed_items: list[str])
|
|
|
264
283
|
for script_file in sorted(plugin_scripts_dir.iterdir()):
|
|
265
284
|
if script_file.name.startswith("__"):
|
|
266
285
|
continue
|
|
286
|
+
# copy2 on a directory raises IsADirectoryError and aborts the install
|
|
287
|
+
# halfway with nothing rolled back, so a pack that ships scripts/bin/
|
|
288
|
+
# or a stray __pycache__ would break it.
|
|
289
|
+
if not script_file.is_file():
|
|
290
|
+
continue
|
|
267
291
|
dest = scripts_dest / script_file.name
|
|
268
292
|
shutil.copy2(script_file, dest)
|
|
269
293
|
if script_file.suffix in (".py", ".sh"):
|
|
@@ -271,8 +295,13 @@ def _copy_plugin_scripts(name: str, pack_dir: Path, installed_items: list[str])
|
|
|
271
295
|
print(f" Copied script: {script_file.name}")
|
|
272
296
|
installed_items.append(f"script:{dest}")
|
|
273
297
|
|
|
274
|
-
|
|
275
|
-
|
|
298
|
+
# `init.py` is the generic name; `init_db.py` predates it and is what
|
|
299
|
+
# memory-pack ships. A pack whose init script is named anything else is
|
|
300
|
+
# silently never run, and the install still reports success.
|
|
301
|
+
for candidate in ("init.py", "init_db.py"):
|
|
302
|
+
init_script = plugin_scripts_dir / candidate
|
|
303
|
+
if not init_script.is_file():
|
|
304
|
+
continue
|
|
276
305
|
result = subprocess.run(
|
|
277
306
|
["python3", str(init_script)],
|
|
278
307
|
capture_output=True,
|
|
@@ -280,8 +309,10 @@ def _copy_plugin_scripts(name: str, pack_dir: Path, installed_items: list[str])
|
|
|
280
309
|
)
|
|
281
310
|
if result.returncode == 0 and result.stdout.strip():
|
|
282
311
|
print(f" Init: {result.stdout.strip()}")
|
|
283
|
-
elif result.returncode != 0
|
|
284
|
-
|
|
312
|
+
elif result.returncode != 0:
|
|
313
|
+
detail = result.stderr.strip() or result.stdout.strip() or "no output"
|
|
314
|
+
print(f" WARN init failed: {detail}")
|
|
315
|
+
break
|
|
285
316
|
|
|
286
317
|
|
|
287
318
|
def _copy_plugin_hook_scripts(name: str, hook_specs: list[dict], installed_items: list[str]) -> None:
|
|
@@ -908,6 +939,10 @@ def install_pack(name: str, editor: str) -> bool:
|
|
|
908
939
|
if name not in installed:
|
|
909
940
|
installed.append(name)
|
|
910
941
|
_set_installed(state, editor, installed)
|
|
942
|
+
# Recorded so `update --all` can skip a pack whose manifest has not moved.
|
|
943
|
+
# Without it, update removes and reinstalls every pack every time, which for
|
|
944
|
+
# a pack that downloads a binary means refetching it on every core update.
|
|
945
|
+
_record_version(state, editor, name, str(pack.get("version", "")))
|
|
911
946
|
save_state(state)
|
|
912
947
|
return True
|
|
913
948
|
|
|
@@ -941,17 +976,40 @@ def remove_pack(name: str, editor: str) -> bool:
|
|
|
941
976
|
|
|
942
977
|
installed = [p for p in installed if p != name]
|
|
943
978
|
_set_installed(state, editor, installed)
|
|
979
|
+
_forget_version(state, editor, name)
|
|
944
980
|
save_state(state)
|
|
945
981
|
return True
|
|
946
982
|
|
|
947
983
|
|
|
948
|
-
def
|
|
984
|
+
def pack_update_pending(name: str, editor: str) -> tuple[bool, str, str]:
|
|
985
|
+
"""(needs_update, installed_version, available_version) for one pack."""
|
|
986
|
+
state = load_state()
|
|
987
|
+
if name not in _installed_for(state, editor):
|
|
988
|
+
return False, "", ""
|
|
989
|
+
pack = find_pack(name)
|
|
990
|
+
if not pack:
|
|
991
|
+
return False, _installed_version(state, editor, name), ""
|
|
992
|
+
available = str(pack.get("version", ""))
|
|
993
|
+
current = _installed_version(state, editor, name)
|
|
994
|
+
return current != available, current, available
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def update_pack(name: str, editor: str, *, force: bool = False) -> bool:
|
|
949
998
|
state = load_state()
|
|
950
999
|
if name not in _installed_for(state, editor):
|
|
951
1000
|
print(f" Plugin '{name}' is not installed for {editor} — use 'install' instead")
|
|
952
1001
|
return False
|
|
953
1002
|
|
|
954
|
-
|
|
1003
|
+
pending, current, available = pack_update_pending(name, editor)
|
|
1004
|
+
if not pending and not force:
|
|
1005
|
+
# Silent no-op by design: `ai-toolkit update` runs this for every
|
|
1006
|
+
# installed pack on every invocation.
|
|
1007
|
+
return True
|
|
1008
|
+
|
|
1009
|
+
if current and available:
|
|
1010
|
+
print(f" Updating: {name} for {editor} ({current} -> {available})")
|
|
1011
|
+
else:
|
|
1012
|
+
print(f" Updating: {name} for {editor}")
|
|
955
1013
|
remove_pack(name, editor)
|
|
956
1014
|
return install_pack(name, editor)
|
|
957
1015
|
|
|
@@ -1090,6 +1148,34 @@ def _show_memory_stats() -> None:
|
|
|
1090
1148
|
print(f" DB: {_human_size(MEMORY_DB.stat().st_size)} (error reading stats)")
|
|
1091
1149
|
|
|
1092
1150
|
|
|
1151
|
+
def _show_pack_status(name: str, pack_dir: Path) -> None:
|
|
1152
|
+
"""Let a pack report its own state via scripts/status.py.
|
|
1153
|
+
|
|
1154
|
+
Generic counterpart to the install-time init.py hook. Before this, anything
|
|
1155
|
+
beyond a hook listing meant another hardcoded `if name == ...` branch, which
|
|
1156
|
+
is why memory-pack is the only pack that ever reported anything.
|
|
1157
|
+
|
|
1158
|
+
The script owns its output format; it is indented and shown verbatim.
|
|
1159
|
+
Failure is not an error: status must never be the thing that breaks.
|
|
1160
|
+
"""
|
|
1161
|
+
status_script = pack_dir / "scripts" / "status.py"
|
|
1162
|
+
if not status_script.is_file():
|
|
1163
|
+
return
|
|
1164
|
+
try:
|
|
1165
|
+
result = subprocess.run(
|
|
1166
|
+
["python3", str(status_script)],
|
|
1167
|
+
capture_output=True,
|
|
1168
|
+
text=True,
|
|
1169
|
+
timeout=15,
|
|
1170
|
+
)
|
|
1171
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
1172
|
+
print(f" (status unavailable: {exc})")
|
|
1173
|
+
return
|
|
1174
|
+
stream = result.stdout if result.stdout.strip() else result.stderr
|
|
1175
|
+
for line in stream.strip().splitlines():
|
|
1176
|
+
print(f" {line}")
|
|
1177
|
+
|
|
1178
|
+
|
|
1093
1179
|
def cmd_status(editors: list[str]) -> None:
|
|
1094
1180
|
state = load_state()
|
|
1095
1181
|
shown = False
|
|
@@ -1117,6 +1203,8 @@ def cmd_status(editors: list[str]) -> None:
|
|
|
1117
1203
|
print(f" Hooks: {', '.join(h.name for h in hooks)}")
|
|
1118
1204
|
if name == "memory-pack":
|
|
1119
1205
|
_show_memory_stats()
|
|
1206
|
+
else:
|
|
1207
|
+
_show_pack_status(name, Path(pack["_dir"]))
|
|
1120
1208
|
print()
|
|
1121
1209
|
|
|
1122
1210
|
if not shown and all(not _installed_for(state, editor) for editor in editors):
|
|
@@ -1199,24 +1287,56 @@ def _cmd_remove(args: list[str], editors: list[str]) -> None:
|
|
|
1199
1287
|
def _cmd_update(args: list[str], editors: list[str]) -> None:
|
|
1200
1288
|
if not args:
|
|
1201
1289
|
print("Usage: ai-toolkit plugin update [--editor claude|codex|all] <pack-name> [...]")
|
|
1202
|
-
print(" ai-toolkit plugin update [--editor claude|codex|all] --all")
|
|
1290
|
+
print(" ai-toolkit plugin update [--editor claude|codex|all] --all [--dry-run]")
|
|
1203
1291
|
sys.exit(1)
|
|
1292
|
+
|
|
1293
|
+
dry_run = "--dry-run" in args or "--list" in args
|
|
1294
|
+
force = "--force" in args
|
|
1295
|
+
everything = "--all" in args
|
|
1296
|
+
explicit = [a for a in args if not a.startswith("--")]
|
|
1297
|
+
|
|
1204
1298
|
state = load_state()
|
|
1205
1299
|
for editor in editors:
|
|
1206
|
-
names = list(_installed_for(state, editor)) if
|
|
1300
|
+
names = list(_installed_for(state, editor)) if everything else explicit
|
|
1207
1301
|
if not names:
|
|
1302
|
+
if everything:
|
|
1303
|
+
# Nothing installed is the common case; do not make the core
|
|
1304
|
+
# update noisy about it.
|
|
1305
|
+
continue
|
|
1208
1306
|
print(f"No plugins installed for {editor}.")
|
|
1209
1307
|
print()
|
|
1210
1308
|
continue
|
|
1211
|
-
|
|
1212
|
-
|
|
1309
|
+
|
|
1310
|
+
if dry_run:
|
|
1311
|
+
pending = []
|
|
1312
|
+
for name in names:
|
|
1313
|
+
needs, current, available = pack_update_pending(name, editor)
|
|
1314
|
+
if needs or force:
|
|
1315
|
+
pending.append(f"{name} ({current or 'unrecorded'} -> {available or 'unknown'})")
|
|
1316
|
+
if pending:
|
|
1317
|
+
print(f"Would update for {editor}: {', '.join(pending)}")
|
|
1318
|
+
else:
|
|
1319
|
+
print(f"All {len(names)} pack(s) up to date for {editor}")
|
|
1320
|
+
print()
|
|
1321
|
+
continue
|
|
1322
|
+
|
|
1213
1323
|
ok = 0
|
|
1324
|
+
failed: list[str] = []
|
|
1214
1325
|
for name in names:
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1326
|
+
try:
|
|
1327
|
+
if update_pack(name, editor, force=force):
|
|
1328
|
+
ok += 1
|
|
1329
|
+
else:
|
|
1330
|
+
failed.append(name)
|
|
1331
|
+
except Exception as exc: # noqa: BLE001
|
|
1332
|
+
# A pack failure must never abort the run: `ai-toolkit update`
|
|
1333
|
+
# calls this after the core update has already succeeded.
|
|
1334
|
+
print(f" WARN update failed for {name}: {exc}")
|
|
1335
|
+
failed.append(name)
|
|
1336
|
+
if everything and (failed or force):
|
|
1219
1337
|
print(f"Updated: {ok}/{len(names)} packs for {editor}")
|
|
1338
|
+
if failed:
|
|
1339
|
+
print(f" Failed: {', '.join(failed)}")
|
|
1220
1340
|
print()
|
|
1221
1341
|
|
|
1222
1342
|
|