@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.
Files changed (63) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +13 -19
  3. package/app/.claude-plugin/plugin.json +1 -1
  4. package/app/hooks/session-end.sh +1 -13
  5. package/app/hooks.json +0 -10
  6. package/app/plugins/README.md +16 -4
  7. package/app/plugins/rtk-pack/README.md +123 -0
  8. package/app/plugins/rtk-pack/hooks/rewrite.sh +79 -0
  9. package/app/plugins/rtk-pack/plugin.json +60 -0
  10. package/app/plugins/rtk-pack/scripts/init.py +252 -0
  11. package/app/plugins/rtk-pack/scripts/status.py +105 -0
  12. package/benchmarks/ecosystem-doctor-snapshot.json +14 -15
  13. package/bin/ai-toolkit.js +15 -2
  14. package/kb/history/completed/output-filter-retirement-20260726.md +128 -0
  15. package/kb/history/completed/rtk-pack-integration-20260726.md +710 -0
  16. package/kb/procedures/maintenance-sop.md +1 -1
  17. package/kb/procedures/release-preparation-sop.md +8 -3
  18. package/kb/procedures/rtk-upstream-sync-sop.md +279 -0
  19. package/kb/reference/architecture-overview.md +2 -3
  20. package/kb/reference/cli-reference.md +4 -14
  21. package/kb/reference/enterprise-config-guide.md +1 -21
  22. package/kb/reference/hooks-catalog.md +3 -60
  23. package/kb/reference/plugin-pack-conventions.md +17 -2
  24. package/kb/reference/supported-tools-registry.md +0 -4
  25. package/llms-full.txt +1171 -402
  26. package/llms.txt +3 -1
  27. package/manifest.json +147 -36
  28. package/package.json +1 -2
  29. package/scripts/audit_skills.py +21 -0
  30. package/scripts/claude_app.py +2 -21
  31. package/scripts/config_cli.py +4 -0
  32. package/scripts/config_merger.py +0 -17
  33. package/scripts/config_validator.py +11 -138
  34. package/scripts/doctor.py +3 -20
  35. package/scripts/install.py +2 -1
  36. package/scripts/install_steps/ai_tools.py +28 -99
  37. package/scripts/install_steps/hooks.py +26 -24
  38. package/scripts/merge-hooks.py +33 -2
  39. package/scripts/output_filter_retirement.py +395 -0
  40. package/scripts/plugin.py +136 -16
  41. package/scripts/schemas/ai-toolkit-config.schema.json +0 -60
  42. package/scripts/uninstall.py +13 -27
  43. package/scripts/verify_rtk_binary.py +335 -0
  44. package/app/hooks/filter-tool-output.sh +0 -76
  45. package/app/output-filter-policy.json +0 -15
  46. package/benchmarks/output-filter/README.md +0 -11
  47. package/benchmarks/output-filter/scenarios.json +0 -25
  48. package/kb/reference/tool-output-filter.md +0 -288
  49. package/scripts/benchmark_output_filter.py +0 -343
  50. package/scripts/output_filter_cli.py +0 -347
  51. package/scripts/output_filter_hook.py +0 -23
  52. package/scripts/tool_output_filter/__init__.py +0 -33
  53. package/scripts/tool_output_filter/contracts.py +0 -173
  54. package/scripts/tool_output_filter/engine.py +0 -260
  55. package/scripts/tool_output_filter/hook_runtime.py +0 -369
  56. package/scripts/tool_output_filter/input.py +0 -56
  57. package/scripts/tool_output_filter/invariants.py +0 -40
  58. package/scripts/tool_output_filter/policy.py +0 -153
  59. package/scripts/tool_output_filter/profiles/__init__.py +0 -68
  60. package/scripts/tool_output_filter/profiles/repeat_lines.py +0 -71
  61. package/scripts/tool_output_filter/profiles/tap_success.py +0 -154
  62. package/scripts/tool_output_filter/recovery.py +0 -846
  63. 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
- init_script = plugin_scripts_dir / "init_db.py"
275
- if init_script.is_file():
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 and result.stderr.strip():
284
- print(f" WARN init failed: {result.stderr.strip()}")
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 update_pack(name: str, editor: str) -> bool:
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
- print(f" Updating: {name} for {editor}")
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 "--all" in args else args
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
- if "--all" in args:
1212
- print(f"Updating {len(names)} installed plugin(s) for {editor}...\n")
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
- if update_pack(name, editor):
1216
- ok += 1
1217
- print()
1218
- if "--all" in args:
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