@softspark/ai-toolkit 4.17.0 → 4.19.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/scripts/plugin.py CHANGED
@@ -2,14 +2,14 @@
2
2
  """ai-toolkit plugin — install, remove, update, clean, and list plugin packs.
3
3
 
4
4
  Usage:
5
- plugin.py list [--editor claude|codex|all]
6
- plugin.py status [--editor claude|codex|all]
7
- plugin.py install [--editor claude|codex|all] <pack-name> [<pack-name> ...]
8
- plugin.py install [--editor claude|codex|all] --all
9
- plugin.py remove [--editor claude|codex|all] <pack-name> [<pack-name> ...]
10
- plugin.py remove [--editor claude|codex|all] --all
11
- plugin.py update [--editor claude|codex|all] <pack-name> [<pack-name> ...]
12
- plugin.py update [--editor claude|codex|all] --all
5
+ plugin.py list [--editor claude|codex|cursor|gemini|all]
6
+ plugin.py status [--editor claude|codex|cursor|gemini|all]
7
+ plugin.py install [--editor claude|codex|cursor|gemini|all] <pack-name> [<pack-name> ...]
8
+ plugin.py install [--editor claude|codex|cursor|gemini|all] --all
9
+ plugin.py remove [--editor claude|codex|cursor|gemini|all] <pack-name> [<pack-name> ...]
10
+ plugin.py remove [--editor claude|codex|cursor|gemini|all] --all
11
+ plugin.py update [--editor claude|codex|cursor|gemini|all] <pack-name> [<pack-name> ...]
12
+ plugin.py update [--editor claude|codex|cursor|gemini|all] --all
13
13
  plugin.py clean <pack-name> [--days N]
14
14
 
15
15
  Actions:
@@ -64,7 +64,38 @@ HOOKS_DIR = _HOOKS_DIR
64
64
  PLUGINS_STATE_FILE = TOOLKIT_DATA_DIR / "plugins.json"
65
65
  MEMORY_DB = TOOLKIT_DATA_DIR / "memory.db"
66
66
 
67
- VALID_EDITORS = ("claude", "codex")
67
+ VALID_EDITORS = ("claude", "codex", "cursor", "gemini")
68
+
69
+ # Runtimes whose hook config is a JSON document we merge a single pack entry
70
+ # into, rather than a surface with its own installer. Everything below is read
71
+ # from the matching scripts/generate_<runtime>_hooks.py.
72
+ #
73
+ # `source_tag` deliberately differs from those generators' own `SOURCE_TAG`
74
+ # ("ai-toolkit"): their strip predicates match that value exactly, so a pack
75
+ # entry tagged `ai-toolkit-plugin-<pack>` survives a core regeneration instead
76
+ # of being deleted as legacy output.
77
+ #
78
+ # Pack hooks are user-scope only. Cursor's project manifest is deliberately
79
+ # self-contained so cloud agents can read it (tests/test_cursor.bats), and a
80
+ # `$HOME/.softspark` command would break that; `~/.cursor/hooks.json` is a local
81
+ # file where a home-relative command is correct.
82
+ JSON_HOOK_RUNTIMES: dict[str, dict] = {
83
+ "cursor": {
84
+ "config": Path.home() / ".cursor" / "hooks.json",
85
+ # Claude event name -> this runtime's event name. An event with no
86
+ # mapping is skipped loudly rather than guessed at.
87
+ "events": {"PreToolUse": "beforeShellExecution"},
88
+ "shape": "flat", # entry is the command record itself
89
+ "timeout": 10,
90
+ },
91
+ "gemini": {
92
+ "config": Path.home() / ".gemini" / "settings.json",
93
+ "events": {"PreToolUse": "BeforeTool", "PostToolUse": "AfterTool"},
94
+ "shape": "nested", # entry wraps a hooks[] list, optional matcher
95
+ "matcher": "run_shell_command",
96
+ "root_key": "hooks", # hooks live under a key inside a larger document
97
+ },
98
+ }
68
99
  CODEX_PLUGIN_NAME_PATTERN = re.compile(r"[a-z0-9][a-z0-9-]*")
69
100
  CODEX_PLUGIN_ASSET_MARKER = "# ai-toolkit-managed: codex-plugin-hook"
70
101
 
@@ -74,12 +105,9 @@ CODEX_PLUGIN_ASSET_MARKER = "# ai-toolkit-managed: codex-plugin-hook"
74
105
  # ---------------------------------------------------------------------------
75
106
 
76
107
  def _empty_state() -> dict:
77
- return {
78
- "targets": {
79
- "claude": {"installed": []},
80
- "codex": {"installed": []},
81
- }
82
- }
108
+ # Built from VALID_EDITORS so adding a runtime cannot leave load_state()
109
+ # indexing a key that was never created.
110
+ return {"targets": {editor: {"installed": [], "versions": {}} for editor in VALID_EDITORS}}
83
111
 
84
112
 
85
113
  def load_state() -> dict:
@@ -103,6 +131,13 @@ def load_state() -> dict:
103
131
  installed = targets.get(editor, {}).get("installed", [])
104
132
  if isinstance(installed, list):
105
133
  state["targets"][editor]["installed"] = sorted(set(installed))
134
+ # Absent in state written before versions were tracked, so
135
+ # every pack looks stale once and is updated exactly once.
136
+ versions = targets.get(editor, {}).get("versions", {})
137
+ if isinstance(versions, dict):
138
+ state["targets"][editor]["versions"] = {
139
+ k: v for k, v in versions.items() if isinstance(v, str)
140
+ }
106
141
  return state
107
142
 
108
143
 
@@ -118,6 +153,18 @@ def _installed_for(state: dict, editor: str) -> list[str]:
118
153
  return list(state.get("targets", {}).get(editor, {}).get("installed", []))
119
154
 
120
155
 
156
+ def _installed_version(state: dict, editor: str, name: str) -> str:
157
+ return state.get("targets", {}).get(editor, {}).get("versions", {}).get(name, "")
158
+
159
+
160
+ def _record_version(state: dict, editor: str, name: str, version: str) -> None:
161
+ state.setdefault("targets", {}).setdefault(editor, {}).setdefault("versions", {})[name] = version
162
+
163
+
164
+ def _forget_version(state: dict, editor: str, name: str) -> None:
165
+ state.get("targets", {}).get(editor, {}).get("versions", {}).pop(name, None)
166
+
167
+
121
168
  def _set_installed(state: dict, editor: str, names: list[str]) -> None:
122
169
  state.setdefault("targets", {}).setdefault(editor, {})
123
170
  state["targets"][editor]["installed"] = sorted(set(names))
@@ -264,6 +311,11 @@ def _copy_plugin_scripts(name: str, pack_dir: Path, installed_items: list[str])
264
311
  for script_file in sorted(plugin_scripts_dir.iterdir()):
265
312
  if script_file.name.startswith("__"):
266
313
  continue
314
+ # copy2 on a directory raises IsADirectoryError and aborts the install
315
+ # halfway with nothing rolled back, so a pack that ships scripts/bin/
316
+ # or a stray __pycache__ would break it.
317
+ if not script_file.is_file():
318
+ continue
267
319
  dest = scripts_dest / script_file.name
268
320
  shutil.copy2(script_file, dest)
269
321
  if script_file.suffix in (".py", ".sh"):
@@ -271,8 +323,13 @@ def _copy_plugin_scripts(name: str, pack_dir: Path, installed_items: list[str])
271
323
  print(f" Copied script: {script_file.name}")
272
324
  installed_items.append(f"script:{dest}")
273
325
 
274
- init_script = plugin_scripts_dir / "init_db.py"
275
- if init_script.is_file():
326
+ # `init.py` is the generic name; `init_db.py` predates it and is what
327
+ # memory-pack ships. A pack whose init script is named anything else is
328
+ # silently never run, and the install still reports success.
329
+ for candidate in ("init.py", "init_db.py"):
330
+ init_script = plugin_scripts_dir / candidate
331
+ if not init_script.is_file():
332
+ continue
276
333
  result = subprocess.run(
277
334
  ["python3", str(init_script)],
278
335
  capture_output=True,
@@ -280,8 +337,10 @@ def _copy_plugin_scripts(name: str, pack_dir: Path, installed_items: list[str])
280
337
  )
281
338
  if result.returncode == 0 and result.stdout.strip():
282
339
  print(f" Init: {result.stdout.strip()}")
283
- elif result.returncode != 0 and result.stderr.strip():
284
- print(f" WARN init failed: {result.stderr.strip()}")
340
+ elif result.returncode != 0:
341
+ detail = result.stderr.strip() or result.stdout.strip() or "no output"
342
+ print(f" WARN init failed: {detail}")
343
+ break
285
344
 
286
345
 
287
346
  def _copy_plugin_hook_scripts(name: str, hook_specs: list[dict], installed_items: list[str]) -> None:
@@ -305,6 +364,142 @@ def _plugin_hook_command(name: str, spec: dict) -> str:
305
364
  return f"\"$HOME/.softspark/ai-toolkit/hooks/plugin-{name}-{spec['name']}\""
306
365
 
307
366
 
367
+ def _json_runtime_entry(runtime: str, name: str, spec: dict) -> dict:
368
+ """One hook entry in this runtime's schema, tagged as owned by the pack."""
369
+ cfg = JSON_HOOK_RUNTIMES[runtime]
370
+ command = _plugin_hook_command(name, spec)
371
+ # The hook is one script for every runtime; the target is its first
372
+ # argument, so a pack can branch on which host is calling rather than
373
+ # always assuming Claude.
374
+ command = f"{command} {runtime}"
375
+ source_tag = f"ai-toolkit-plugin-{name}"
376
+
377
+ if cfg["shape"] == "flat":
378
+ entry = {"_source": source_tag, "command": command}
379
+ if cfg.get("timeout"):
380
+ entry["timeout"] = cfg["timeout"]
381
+ return entry
382
+
383
+ entry = {"_source": source_tag, "hooks": [{"type": "command", "command": command}]}
384
+ if cfg.get("matcher"):
385
+ entry["matcher"] = cfg["matcher"]
386
+ return entry
387
+
388
+
389
+ def _json_runtime_hooks_block(runtime: str, document: dict) -> dict:
390
+ cfg = JSON_HOOK_RUNTIMES[runtime]
391
+ root = cfg.get("root_key")
392
+ if root:
393
+ block = document.get(root)
394
+ return block if isinstance(block, dict) else {}
395
+ block = document.get("hooks")
396
+ return block if isinstance(block, dict) else {}
397
+
398
+
399
+ def _json_runtime_set_hooks(runtime: str, document: dict, hooks: dict) -> None:
400
+ cfg = JSON_HOOK_RUNTIMES[runtime]
401
+ document[cfg.get("root_key") or "hooks"] = hooks
402
+
403
+
404
+ def _merge_json_runtime_hooks(runtime: str, name: str, hook_specs: list[dict]) -> bool:
405
+ """Add this pack's hooks to a JSON-configured runtime. True if anything landed."""
406
+ cfg = JSON_HOOK_RUNTIMES[runtime]
407
+ config_path: Path = cfg["config"]
408
+ if config_path.is_symlink() or config_path.parent.is_symlink():
409
+ print(f" WARN refusing symlinked {runtime} config: {config_path}")
410
+ return False
411
+
412
+ source_tag = f"ai-toolkit-plugin-{name}"
413
+ document = _load_json(config_path, {})
414
+ hooks = _json_runtime_hooks_block(runtime, document)
415
+
416
+ # Drop this pack's previous entries everywhere before appending, so a
417
+ # re-install cannot duplicate and two hooks on one event both survive.
418
+ hooks = {
419
+ event: [e for e in entries if not (isinstance(e, dict) and e.get("_source") == source_tag)]
420
+ if isinstance(entries, list) else entries
421
+ for event, entries in hooks.items()
422
+ }
423
+
424
+ landed = 0
425
+ for spec in hook_specs:
426
+ if spec["is_core"]:
427
+ continue
428
+ target_event = cfg["events"].get(spec["event"])
429
+ if not target_event:
430
+ print(
431
+ f" WARN {runtime} has no equivalent of {spec['event']} "
432
+ f"for {spec['name']}, hook not registered"
433
+ )
434
+ continue
435
+ hooks.setdefault(target_event, []).append(_json_runtime_entry(runtime, name, spec))
436
+ landed += 1
437
+
438
+ if not landed:
439
+ return False
440
+
441
+ hooks = {event: entries for event, entries in hooks.items() if entries}
442
+ _json_runtime_set_hooks(runtime, document, hooks)
443
+ _write_json(config_path, document)
444
+ print(f" Merged hooks into {config_path}")
445
+ return True
446
+
447
+
448
+ def _strip_json_runtime_hooks(runtime: str, name: str) -> None:
449
+ cfg = JSON_HOOK_RUNTIMES[runtime]
450
+ config_path: Path = cfg["config"]
451
+ if not config_path.is_file() or config_path.is_symlink():
452
+ return
453
+ source_tag = f"ai-toolkit-plugin-{name}"
454
+ document = _load_json(config_path, {})
455
+ hooks = _json_runtime_hooks_block(runtime, document)
456
+ kept = {}
457
+ removed = 0
458
+ for event, entries in hooks.items():
459
+ if not isinstance(entries, list):
460
+ kept[event] = entries
461
+ continue
462
+ survivors = [
463
+ e for e in entries
464
+ if not (isinstance(e, dict) and e.get("_source") == source_tag)
465
+ ]
466
+ removed += len(entries) - len(survivors)
467
+ if survivors:
468
+ kept[event] = survivors
469
+ if not removed:
470
+ return
471
+ _json_runtime_set_hooks(runtime, document, kept)
472
+ _write_json(config_path, document)
473
+ print(f" Stripped hooks from {config_path}")
474
+
475
+
476
+ def install_pack_json_runtime(runtime: str, name: str, pack: dict, pack_dir: Path) -> bool:
477
+ hook_specs = _resolve_pack_hooks(pack, pack_dir)
478
+ installed_items: list[str] = []
479
+ _copy_plugin_scripts(name, pack_dir, installed_items)
480
+ _copy_plugin_hook_scripts(name, hook_specs, installed_items)
481
+ if not _merge_json_runtime_hooks(runtime, name, hook_specs):
482
+ print(f" WARN nothing registered for {runtime}; pack files are installed but inert")
483
+ print(f" Done: {name} for {runtime} ({len(installed_items)} file items)")
484
+ return True
485
+
486
+
487
+ def remove_pack_json_runtime(
488
+ runtime: str, name: str, pack: dict, pack_dir: Path, *, keep_shared_assets: bool
489
+ ) -> bool:
490
+ _strip_json_runtime_hooks(runtime, name)
491
+ if not keep_shared_assets:
492
+ for hook in HOOKS_DIR.glob(f"plugin-{name}-*"):
493
+ hook.unlink()
494
+ print(f" Removed hook: {hook.name}")
495
+ scripts_dir = TOOLKIT_DATA_DIR / "plugin-scripts" / name
496
+ if scripts_dir.is_dir():
497
+ shutil.rmtree(scripts_dir)
498
+ print(f" Removed scripts: {scripts_dir}")
499
+ print(f" Done: removed {name} from {runtime}")
500
+ return True
501
+
502
+
308
503
  def _load_json(path: Path, default: dict) -> dict:
309
504
  if not path.is_file():
310
505
  return json.loads(json.dumps(default))
@@ -399,6 +594,16 @@ def _merge_claude_hooks(name: str, hook_specs: list[dict]) -> None:
399
594
  hooks = settings.setdefault("hooks", {})
400
595
  source_tag = f"ai-toolkit-plugin-{name}"
401
596
 
597
+ # Drop this pack's previous entries once per event, before appending any.
598
+ # Stripping inside the loop also removed the entry appended by an earlier
599
+ # iteration, so a pack shipping two non-core hooks on the same event kept
600
+ # only the last one. memory-pack does not hit this because its two hooks
601
+ # sit on different events.
602
+ events = {spec["event"] for spec in hook_specs if not spec["is_core"]}
603
+ for event in events:
604
+ existing = hooks.get(event, [])
605
+ hooks[event] = [h for h in existing if h.get("_source") != source_tag]
606
+
402
607
  for spec in hook_specs:
403
608
  if spec["is_core"]:
404
609
  # Base Claude install already owns core hooks.
@@ -413,10 +618,7 @@ def _merge_claude_hooks(name: str, hook_specs: list[dict]) -> None:
413
618
  }
414
619
  ],
415
620
  }
416
- event_hooks = hooks.setdefault(spec["event"], [])
417
- event_hooks = [h for h in event_hooks if h.get("_source") != source_tag]
418
- event_hooks.append(entry)
419
- hooks[spec["event"]] = event_hooks
621
+ hooks.setdefault(spec["event"], []).append(entry)
420
622
 
421
623
  _write_json(settings_path, settings)
422
624
  print(" Merged hooks into ~/.claude/settings.json")
@@ -482,12 +684,49 @@ def install_pack_claude(name: str, pack: dict, pack_dir: Path) -> bool:
482
684
  return True
483
685
 
484
686
 
687
+ def _remove_claude_pack_links(pack: dict, pack_dir: Path) -> None:
688
+ """Drop skill/agent symlinks this pack owns.
689
+
690
+ Install symlinks both into ~/.claude but removal never did, so every pack
691
+ left them behind. Ownership is decided by where the link resolves: only a
692
+ link into the pack's own directory is removed. A link into app/skills or
693
+ app/agents is a core asset the pack merely referenced, and the base install
694
+ would have created it anyway, so it stays.
695
+ """
696
+ pack_dir = pack_dir.resolve()
697
+
698
+ for skill in pack.get("includes", {}).get("skills", []):
699
+ link = CLAUDE_DIR / "skills" / skill
700
+ if not link.is_symlink():
701
+ continue
702
+ try:
703
+ target = link.resolve()
704
+ except OSError:
705
+ continue
706
+ if target.is_relative_to(pack_dir):
707
+ link.unlink()
708
+ print(f" Removed skill link: {skill}")
709
+
710
+ for agent in pack.get("includes", {}).get("agents", []):
711
+ link = CLAUDE_DIR / "agents" / f"{agent}.md"
712
+ if not link.is_symlink():
713
+ continue
714
+ try:
715
+ target = link.resolve()
716
+ except OSError:
717
+ continue
718
+ if target.is_relative_to(pack_dir):
719
+ link.unlink()
720
+ print(f" Removed agent link: {agent}")
721
+
722
+
485
723
  def remove_pack_claude(name: str, pack: dict, pack_dir: Path, *, keep_shared_assets: bool) -> bool:
486
724
  hook_specs = _resolve_pack_hooks(pack, pack_dir)
487
725
  rule_specs = _resolve_pack_rules(pack, pack_dir)
726
+ _remove_claude_pack_links(pack, pack_dir)
488
727
 
489
728
  if not keep_shared_assets:
490
- for hook in HOOKS_DIR.glob(f"plugin-{name}-*.sh"):
729
+ for hook in HOOKS_DIR.glob(f"plugin-{name}-*"):
491
730
  hook.unlink()
492
731
  print(f" Removed hook: {hook.name}")
493
732
 
@@ -859,7 +1098,7 @@ def remove_pack_codex(name: str, pack: dict, pack_dir: Path, *, keep_shared_asse
859
1098
  _strip_codex_hooks(name)
860
1099
 
861
1100
  if CODEX_HOOKS_DIR.is_dir():
862
- for hook in CODEX_HOOKS_DIR.glob(f"plugin-{name}-*.sh"):
1101
+ for hook in CODEX_HOOKS_DIR.glob(f"plugin-{name}-*"):
863
1102
  if hook.is_symlink():
864
1103
  raise RuntimeError(f"Refusing symlinked Codex plugin hook: {hook}")
865
1104
  if _is_owned_codex_plugin_asset(hook, name):
@@ -871,7 +1110,7 @@ def remove_pack_codex(name: str, pack: dict, pack_dir: Path, *, keep_shared_asse
871
1110
  if not keep_shared_assets:
872
1111
  # Clean paths used by releases before native Codex plugin assets moved
873
1112
  # under $CODEX_HOME. Claude still owns these when installed for both.
874
- for hook in HOOKS_DIR.glob(f"plugin-{name}-*.sh"):
1113
+ for hook in HOOKS_DIR.glob(f"plugin-{name}-*"):
875
1114
  hook.unlink()
876
1115
  print(f" Removed hook: {hook.name}")
877
1116
 
@@ -896,10 +1135,30 @@ def install_pack(name: str, editor: str) -> bool:
896
1135
  print(f" Available: {', '.join(p['name'] for p in list_available())}")
897
1136
  return False
898
1137
 
1138
+ # A pack may declare which runtimes it actually works on. Without this a
1139
+ # pack installs everywhere and silently does nothing on the runtimes it was
1140
+ # never built for: a hook that speaks Claude Code's payload format would be
1141
+ # wired into Codex and never fire.
1142
+ supported = pack.get("supported_editors")
1143
+ if isinstance(supported, list) and supported and editor not in supported:
1144
+ print(
1145
+ f" Skipping: {name} does not support {editor} "
1146
+ f"(supported: {', '.join(supported)})"
1147
+ )
1148
+ return False
1149
+
899
1150
  pack_dir = Path(pack["_dir"])
900
1151
  print(f" Installing: {name} for {editor} ({pack.get('description', '')})")
901
1152
 
902
- ok = install_pack_claude(name, pack, pack_dir) if editor == "claude" else install_pack_codex(name, pack, pack_dir)
1153
+ if editor == "claude":
1154
+ ok = install_pack_claude(name, pack, pack_dir)
1155
+ elif editor == "codex":
1156
+ ok = install_pack_codex(name, pack, pack_dir)
1157
+ elif editor in JSON_HOOK_RUNTIMES:
1158
+ ok = install_pack_json_runtime(editor, name, pack, pack_dir)
1159
+ else:
1160
+ print(f" ERROR: no installer for runtime '{editor}'")
1161
+ return False
903
1162
  if not ok:
904
1163
  return False
905
1164
 
@@ -908,6 +1167,10 @@ def install_pack(name: str, editor: str) -> bool:
908
1167
  if name not in installed:
909
1168
  installed.append(name)
910
1169
  _set_installed(state, editor, installed)
1170
+ # Recorded so `update --all` can skip a pack whose manifest has not moved.
1171
+ # Without it, update removes and reinstalls every pack every time, which for
1172
+ # a pack that downloads a binary means refetching it on every core update.
1173
+ _record_version(state, editor, name, str(pack.get("version", "")))
911
1174
  save_state(state)
912
1175
  return True
913
1176
 
@@ -931,27 +1194,56 @@ def remove_pack(name: str, editor: str) -> bool:
931
1194
  for other in VALID_EDITORS
932
1195
  if other != editor
933
1196
  )
934
- ok = (
935
- remove_pack_claude(name, pack, pack_dir, keep_shared_assets=keep_shared_assets)
936
- if editor == "claude"
937
- else remove_pack_codex(name, pack, pack_dir, keep_shared_assets=keep_shared_assets)
938
- )
1197
+ if editor == "claude":
1198
+ ok = remove_pack_claude(name, pack, pack_dir, keep_shared_assets=keep_shared_assets)
1199
+ elif editor == "codex":
1200
+ ok = remove_pack_codex(name, pack, pack_dir, keep_shared_assets=keep_shared_assets)
1201
+ elif editor in JSON_HOOK_RUNTIMES:
1202
+ ok = remove_pack_json_runtime(
1203
+ editor, name, pack, pack_dir, keep_shared_assets=keep_shared_assets
1204
+ )
1205
+ else:
1206
+ print(f" ERROR: no remover for runtime '{editor}'")
1207
+ return False
939
1208
  if not ok:
940
1209
  return False
941
1210
 
942
1211
  installed = [p for p in installed if p != name]
943
1212
  _set_installed(state, editor, installed)
1213
+ _forget_version(state, editor, name)
944
1214
  save_state(state)
945
1215
  return True
946
1216
 
947
1217
 
948
- def update_pack(name: str, editor: str) -> bool:
1218
+ def pack_update_pending(name: str, editor: str) -> tuple[bool, str, str]:
1219
+ """(needs_update, installed_version, available_version) for one pack."""
1220
+ state = load_state()
1221
+ if name not in _installed_for(state, editor):
1222
+ return False, "", ""
1223
+ pack = find_pack(name)
1224
+ if not pack:
1225
+ return False, _installed_version(state, editor, name), ""
1226
+ available = str(pack.get("version", ""))
1227
+ current = _installed_version(state, editor, name)
1228
+ return current != available, current, available
1229
+
1230
+
1231
+ def update_pack(name: str, editor: str, *, force: bool = False) -> bool:
949
1232
  state = load_state()
950
1233
  if name not in _installed_for(state, editor):
951
1234
  print(f" Plugin '{name}' is not installed for {editor} — use 'install' instead")
952
1235
  return False
953
1236
 
954
- print(f" Updating: {name} for {editor}")
1237
+ pending, current, available = pack_update_pending(name, editor)
1238
+ if not pending and not force:
1239
+ # Silent no-op by design: `ai-toolkit update` runs this for every
1240
+ # installed pack on every invocation.
1241
+ return True
1242
+
1243
+ if current and available:
1244
+ print(f" Updating: {name} for {editor} ({current} -> {available})")
1245
+ else:
1246
+ print(f" Updating: {name} for {editor}")
955
1247
  remove_pack(name, editor)
956
1248
  return install_pack(name, editor)
957
1249
 
@@ -1090,6 +1382,34 @@ def _show_memory_stats() -> None:
1090
1382
  print(f" DB: {_human_size(MEMORY_DB.stat().st_size)} (error reading stats)")
1091
1383
 
1092
1384
 
1385
+ def _show_pack_status(name: str, pack_dir: Path) -> None:
1386
+ """Let a pack report its own state via scripts/status.py.
1387
+
1388
+ Generic counterpart to the install-time init.py hook. Before this, anything
1389
+ beyond a hook listing meant another hardcoded `if name == ...` branch, which
1390
+ is why memory-pack is the only pack that ever reported anything.
1391
+
1392
+ The script owns its output format; it is indented and shown verbatim.
1393
+ Failure is not an error: status must never be the thing that breaks.
1394
+ """
1395
+ status_script = pack_dir / "scripts" / "status.py"
1396
+ if not status_script.is_file():
1397
+ return
1398
+ try:
1399
+ result = subprocess.run(
1400
+ ["python3", str(status_script)],
1401
+ capture_output=True,
1402
+ text=True,
1403
+ timeout=15,
1404
+ )
1405
+ except (OSError, subprocess.SubprocessError) as exc:
1406
+ print(f" (status unavailable: {exc})")
1407
+ return
1408
+ stream = result.stdout if result.stdout.strip() else result.stderr
1409
+ for line in stream.strip().splitlines():
1410
+ print(f" {line}")
1411
+
1412
+
1093
1413
  def cmd_status(editors: list[str]) -> None:
1094
1414
  state = load_state()
1095
1415
  shown = False
@@ -1108,15 +1428,17 @@ def cmd_status(editors: list[str]) -> None:
1108
1428
  continue
1109
1429
  print(f" {name}: {pack.get('description', '')}")
1110
1430
  if editor == "claude":
1111
- hooks = list(HOOKS_DIR.glob(f"plugin-{name}-*.sh"))
1431
+ hooks = list(HOOKS_DIR.glob(f"plugin-{name}-*"))
1112
1432
  if hooks:
1113
1433
  print(f" Hooks: {', '.join(h.name for h in hooks)}")
1114
1434
  elif editor == "codex":
1115
- hooks = sorted(CODEX_HOOKS_DIR.glob(f"plugin-{name}-*.sh"))
1435
+ hooks = sorted(CODEX_HOOKS_DIR.glob(f"plugin-{name}-*"))
1116
1436
  if hooks:
1117
1437
  print(f" Hooks: {', '.join(h.name for h in hooks)}")
1118
1438
  if name == "memory-pack":
1119
1439
  _show_memory_stats()
1440
+ else:
1441
+ _show_pack_status(name, Path(pack["_dir"]))
1120
1442
  print()
1121
1443
 
1122
1444
  if not shown and all(not _installed_for(state, editor) for editor in editors):
@@ -1162,8 +1484,8 @@ def _parse_editors(args: list[str]) -> tuple[list[str], list[str]]:
1162
1484
 
1163
1485
  def _cmd_install(args: list[str], editors: list[str]) -> None:
1164
1486
  if not args:
1165
- print("Usage: ai-toolkit plugin install [--editor claude|codex|all] <pack-name> [...]")
1166
- print(" ai-toolkit plugin install [--editor claude|codex|all] --all")
1487
+ print("Usage: ai-toolkit plugin install [--editor claude|codex|cursor|gemini|all] <pack-name> [...]")
1488
+ print(" ai-toolkit plugin install [--editor claude|codex|cursor|gemini|all] --all")
1167
1489
  sys.exit(1)
1168
1490
  names = [pack["name"] for pack in list_available()] if "--all" in args else args
1169
1491
  for editor in editors:
@@ -1181,8 +1503,8 @@ def _cmd_install(args: list[str], editors: list[str]) -> None:
1181
1503
 
1182
1504
  def _cmd_remove(args: list[str], editors: list[str]) -> None:
1183
1505
  if not args:
1184
- print("Usage: ai-toolkit plugin remove [--editor claude|codex|all] <pack-name> [...]")
1185
- print(" ai-toolkit plugin remove [--editor claude|codex|all] --all")
1506
+ print("Usage: ai-toolkit plugin remove [--editor claude|codex|cursor|gemini|all] <pack-name> [...]")
1507
+ print(" ai-toolkit plugin remove [--editor claude|codex|cursor|gemini|all] --all")
1186
1508
  sys.exit(1)
1187
1509
  state = load_state()
1188
1510
  for editor in editors:
@@ -1198,25 +1520,57 @@ def _cmd_remove(args: list[str], editors: list[str]) -> None:
1198
1520
 
1199
1521
  def _cmd_update(args: list[str], editors: list[str]) -> None:
1200
1522
  if not args:
1201
- print("Usage: ai-toolkit plugin update [--editor claude|codex|all] <pack-name> [...]")
1202
- print(" ai-toolkit plugin update [--editor claude|codex|all] --all")
1523
+ print("Usage: ai-toolkit plugin update [--editor claude|codex|cursor|gemini|all] <pack-name> [...]")
1524
+ print(" ai-toolkit plugin update [--editor claude|codex|cursor|gemini|all] --all [--dry-run]")
1203
1525
  sys.exit(1)
1526
+
1527
+ dry_run = "--dry-run" in args or "--list" in args
1528
+ force = "--force" in args
1529
+ everything = "--all" in args
1530
+ explicit = [a for a in args if not a.startswith("--")]
1531
+
1204
1532
  state = load_state()
1205
1533
  for editor in editors:
1206
- names = list(_installed_for(state, editor)) if "--all" in args else args
1534
+ names = list(_installed_for(state, editor)) if everything else explicit
1207
1535
  if not names:
1536
+ if everything:
1537
+ # Nothing installed is the common case; do not make the core
1538
+ # update noisy about it.
1539
+ continue
1208
1540
  print(f"No plugins installed for {editor}.")
1209
1541
  print()
1210
1542
  continue
1211
- if "--all" in args:
1212
- print(f"Updating {len(names)} installed plugin(s) for {editor}...\n")
1543
+
1544
+ if dry_run:
1545
+ pending = []
1546
+ for name in names:
1547
+ needs, current, available = pack_update_pending(name, editor)
1548
+ if needs or force:
1549
+ pending.append(f"{name} ({current or 'unrecorded'} -> {available or 'unknown'})")
1550
+ if pending:
1551
+ print(f"Would update for {editor}: {', '.join(pending)}")
1552
+ else:
1553
+ print(f"All {len(names)} pack(s) up to date for {editor}")
1554
+ print()
1555
+ continue
1556
+
1213
1557
  ok = 0
1558
+ failed: list[str] = []
1214
1559
  for name in names:
1215
- if update_pack(name, editor):
1216
- ok += 1
1217
- print()
1218
- if "--all" in args:
1560
+ try:
1561
+ if update_pack(name, editor, force=force):
1562
+ ok += 1
1563
+ else:
1564
+ failed.append(name)
1565
+ except Exception as exc: # noqa: BLE001
1566
+ # A pack failure must never abort the run: `ai-toolkit update`
1567
+ # calls this after the core update has already succeeded.
1568
+ print(f" WARN update failed for {name}: {exc}")
1569
+ failed.append(name)
1570
+ if everything and (failed or force):
1219
1571
  print(f"Updated: {ok}/{len(names)} packs for {editor}")
1572
+ if failed:
1573
+ print(f" Failed: {', '.join(failed)}")
1220
1574
  print()
1221
1575
 
1222
1576