@softspark/ai-toolkit 4.18.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/llms.txt CHANGED
@@ -22,15 +22,16 @@
22
22
  - [Retirement: Native Tool-Output Filter](kb/history/completed/output-filter-retirement-20260726.md)
23
23
  - [Plan: Output & Token Discipline](kb/history/completed/output-token-discipline-plan-20260504.md)
24
24
  - [rtk Pack Integration](kb/history/completed/rtk-pack-integration-20260726.md)
25
+ - [Retirement: rtk-pack](kb/history/completed/rtk-pack-retirement-20260727.md)
25
26
  - [How-To Guides](kb/howto/README.md)
26
27
  - [Plan: Cloud Security Pack — Multi-Cloud Audit](kb/planning/cloud-security-pack-plan.md)
27
28
  - [Plan: Drop Cascade hooks after 2026-07-01 sunset](kb/planning/drop-cascade-hooks-after-sunset.md)
28
29
  - [PRD: MCP Context Trim v4.0](kb/planning/mcp-context-trim-v4-prd.md)
29
30
  - [SOP: Ecosystem Sync](kb/procedures/ecosystem-sync-sop.md)
30
31
  - [SOP: AI Toolkit Maintenance](kb/procedures/maintenance-sop.md)
32
+ - [SOP: Post-Release Testing](kb/procedures/post-release-testing-sop.md)
31
33
  - [SOP: Release Preparation](kb/procedures/release-preparation-sop.md)
32
34
  - [SOP: Release Verification](kb/procedures/release-verification-sop.md)
33
- - [SOP: rtk Upstream Sync](kb/procedures/rtk-upstream-sync-sop.md)
34
35
  - [Agents Catalog](kb/reference/agents-catalog.md)
35
36
  - [Anti-Pattern Registry Format](kb/reference/anti-pattern-registry-format.md)
36
37
  - [AI Toolkit Architecture](kb/reference/architecture-overview.md)
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "4.18.0",
2
+ "version": "4.19.0",
3
3
  "components": {
4
4
  "agents": {
5
5
  "description": "44 specialized agents (orchestrator, backend, frontend, security, devops, etc.)",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softspark/ai-toolkit",
3
- "version": "4.18.0",
3
+ "version": "4.19.0",
4
4
  "description": "AI coding toolkit: 108 skills, 44 agents, 12 developer-tool integrations, recoverable native tool-output filtering, Claude Chat/Cowork export, safety constitution, SARIF audit, and signed npm provenance.",
5
5
  "keywords": [
6
6
  "claude",
@@ -455,7 +455,7 @@ def audit(toolkit_root: Path) -> list[Finding]:
455
455
  scan_unicode(agent_md, findings)
456
456
 
457
457
  # Scan plugin packs. Pack code ships and executes exactly like skill code,
458
- # and rtk-pack's install script downloads and runs a native binary, so
458
+ # and a pack may carry an install script that runs at install time, so
459
459
  # leaving app/plugins out of the HIGH gate exempted the highest-risk code
460
460
  # in the repo from the check that exists to catch it.
461
461
  plugins = app / "plugins"
@@ -490,3 +490,47 @@ def main() -> None:
490
490
 
491
491
  if __name__ == "__main__":
492
492
  main()
493
+
494
+
495
+ def _cleanup_config_path(target_dir: Path) -> Path | None:
496
+ return Path(target_dir).expanduser() / ".cursor" / "hooks.json"
497
+
498
+
499
+ def _cleanup_write(path: Path, document: dict) -> None:
500
+ path.write_text(json.dumps(document, indent=2, sort_keys=True) + "\n", encoding="utf-8")
501
+
502
+
503
+ def cleanup(target_dir: Path) -> None:
504
+ """Strip this toolkit's hook entries for an uninstall or profile downgrade.
505
+
506
+ Only entries tagged with SOURCE_TAG are removed; user and plugin-pack
507
+ entries are left in place. The file is deleted only when nothing survives,
508
+ so an uninstall does not take a user's own configuration with it.
509
+ """
510
+ config = _cleanup_config_path(target_dir)
511
+ if config is None or not config.is_file() or config.is_symlink():
512
+ return
513
+ try:
514
+ with open(config, encoding="utf-8") as handle:
515
+ document = json.load(handle)
516
+ except (OSError, json.JSONDecodeError):
517
+ return
518
+ if not isinstance(document, dict):
519
+ return
520
+
521
+ hooks = document.get("hooks")
522
+ if not isinstance(hooks, dict):
523
+ return
524
+ survivors = strip_toolkit_hooks(hooks)
525
+ if survivors == hooks:
526
+ return
527
+
528
+ if survivors:
529
+ document["hooks"] = survivors
530
+ else:
531
+ document.pop("hooks", None)
532
+
533
+ if document:
534
+ _cleanup_write(config, document)
535
+ else:
536
+ config.unlink()
@@ -179,3 +179,47 @@ def main() -> None:
179
179
 
180
180
  if __name__ == "__main__":
181
181
  main()
182
+
183
+
184
+ def _cleanup_config_path(target_dir: Path) -> Path | None:
185
+ return Path(target_dir).expanduser() / ".gemini" / "settings.json"
186
+
187
+
188
+ def _cleanup_write(path: Path, document: dict) -> None:
189
+ _write_settings_atomic(path, document)
190
+
191
+
192
+ def cleanup(target_dir: Path) -> None:
193
+ """Strip this toolkit's hook entries for an uninstall or profile downgrade.
194
+
195
+ Only entries tagged with SOURCE_TAG are removed; user and plugin-pack
196
+ entries are left in place. The file is deleted only when nothing survives,
197
+ so an uninstall does not take a user's own configuration with it.
198
+ """
199
+ config = _cleanup_config_path(target_dir)
200
+ if config is None or not config.is_file() or config.is_symlink():
201
+ return
202
+ try:
203
+ with open(config, encoding="utf-8") as handle:
204
+ document = json.load(handle)
205
+ except (OSError, json.JSONDecodeError):
206
+ return
207
+ if not isinstance(document, dict):
208
+ return
209
+
210
+ hooks = document.get("hooks")
211
+ if not isinstance(hooks, dict):
212
+ return
213
+ survivors = strip_toolkit_hooks(hooks)
214
+ if survivors == hooks:
215
+ return
216
+
217
+ if survivors:
218
+ document["hooks"] = survivors
219
+ else:
220
+ document.pop("hooks", None)
221
+
222
+ if document:
223
+ _cleanup_write(config, document)
224
+ else:
225
+ config.unlink()
@@ -286,7 +286,20 @@ def parse_args(argv: list[str]) -> dict:
286
286
  print(f"Unknown option: {arg}")
287
287
  sys.exit(1)
288
288
  else:
289
- cfg["target_dir"] = Path(arg)
289
+ # A bare word that is not an existing directory is almost never a
290
+ # deliberate install target: `ai-toolkit install <pack>` reads like
291
+ # `plugin install <pack>` and silently writes a full toolkit tree
292
+ # into ./<pack>. Require an existing directory or an explicit path
293
+ # separator, so creating a new top-level directory has to be asked
294
+ # for rather than typed by accident.
295
+ candidate = Path(arg)
296
+ looks_like_path = "/" in arg or "\\" in arg or arg.startswith("~") or arg == "."
297
+ if not candidate.is_dir() and not looks_like_path:
298
+ print(f"Refusing to install into a new directory named '{arg}'.")
299
+ print(f" If you meant a plugin pack: ai-toolkit plugin install {arg}")
300
+ print(f" If you really meant a path: ai-toolkit install ./{arg}")
301
+ sys.exit(1)
302
+ cfg["target_dir"] = candidate
290
303
  i += 1
291
304
  return cfg
292
305
 
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": [], "versions": {}},
80
- "codex": {"installed": [], "versions": {}},
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:
@@ -336,6 +364,142 @@ def _plugin_hook_command(name: str, spec: dict) -> str:
336
364
  return f"\"$HOME/.softspark/ai-toolkit/hooks/plugin-{name}-{spec['name']}\""
337
365
 
338
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
+
339
503
  def _load_json(path: Path, default: dict) -> dict:
340
504
  if not path.is_file():
341
505
  return json.loads(json.dumps(default))
@@ -430,6 +594,16 @@ def _merge_claude_hooks(name: str, hook_specs: list[dict]) -> None:
430
594
  hooks = settings.setdefault("hooks", {})
431
595
  source_tag = f"ai-toolkit-plugin-{name}"
432
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
+
433
607
  for spec in hook_specs:
434
608
  if spec["is_core"]:
435
609
  # Base Claude install already owns core hooks.
@@ -444,10 +618,7 @@ def _merge_claude_hooks(name: str, hook_specs: list[dict]) -> None:
444
618
  }
445
619
  ],
446
620
  }
447
- event_hooks = hooks.setdefault(spec["event"], [])
448
- event_hooks = [h for h in event_hooks if h.get("_source") != source_tag]
449
- event_hooks.append(entry)
450
- hooks[spec["event"]] = event_hooks
621
+ hooks.setdefault(spec["event"], []).append(entry)
451
622
 
452
623
  _write_json(settings_path, settings)
453
624
  print(" Merged hooks into ~/.claude/settings.json")
@@ -513,12 +684,49 @@ def install_pack_claude(name: str, pack: dict, pack_dir: Path) -> bool:
513
684
  return True
514
685
 
515
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
+
516
723
  def remove_pack_claude(name: str, pack: dict, pack_dir: Path, *, keep_shared_assets: bool) -> bool:
517
724
  hook_specs = _resolve_pack_hooks(pack, pack_dir)
518
725
  rule_specs = _resolve_pack_rules(pack, pack_dir)
726
+ _remove_claude_pack_links(pack, pack_dir)
519
727
 
520
728
  if not keep_shared_assets:
521
- for hook in HOOKS_DIR.glob(f"plugin-{name}-*.sh"):
729
+ for hook in HOOKS_DIR.glob(f"plugin-{name}-*"):
522
730
  hook.unlink()
523
731
  print(f" Removed hook: {hook.name}")
524
732
 
@@ -890,7 +1098,7 @@ def remove_pack_codex(name: str, pack: dict, pack_dir: Path, *, keep_shared_asse
890
1098
  _strip_codex_hooks(name)
891
1099
 
892
1100
  if CODEX_HOOKS_DIR.is_dir():
893
- for hook in CODEX_HOOKS_DIR.glob(f"plugin-{name}-*.sh"):
1101
+ for hook in CODEX_HOOKS_DIR.glob(f"plugin-{name}-*"):
894
1102
  if hook.is_symlink():
895
1103
  raise RuntimeError(f"Refusing symlinked Codex plugin hook: {hook}")
896
1104
  if _is_owned_codex_plugin_asset(hook, name):
@@ -902,7 +1110,7 @@ def remove_pack_codex(name: str, pack: dict, pack_dir: Path, *, keep_shared_asse
902
1110
  if not keep_shared_assets:
903
1111
  # Clean paths used by releases before native Codex plugin assets moved
904
1112
  # under $CODEX_HOME. Claude still owns these when installed for both.
905
- for hook in HOOKS_DIR.glob(f"plugin-{name}-*.sh"):
1113
+ for hook in HOOKS_DIR.glob(f"plugin-{name}-*"):
906
1114
  hook.unlink()
907
1115
  print(f" Removed hook: {hook.name}")
908
1116
 
@@ -927,10 +1135,30 @@ def install_pack(name: str, editor: str) -> bool:
927
1135
  print(f" Available: {', '.join(p['name'] for p in list_available())}")
928
1136
  return False
929
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
+
930
1150
  pack_dir = Path(pack["_dir"])
931
1151
  print(f" Installing: {name} for {editor} ({pack.get('description', '')})")
932
1152
 
933
- 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
934
1162
  if not ok:
935
1163
  return False
936
1164
 
@@ -966,11 +1194,17 @@ def remove_pack(name: str, editor: str) -> bool:
966
1194
  for other in VALID_EDITORS
967
1195
  if other != editor
968
1196
  )
969
- ok = (
970
- remove_pack_claude(name, pack, pack_dir, keep_shared_assets=keep_shared_assets)
971
- if editor == "claude"
972
- else remove_pack_codex(name, pack, pack_dir, keep_shared_assets=keep_shared_assets)
973
- )
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
974
1208
  if not ok:
975
1209
  return False
976
1210
 
@@ -1194,11 +1428,11 @@ def cmd_status(editors: list[str]) -> None:
1194
1428
  continue
1195
1429
  print(f" {name}: {pack.get('description', '')}")
1196
1430
  if editor == "claude":
1197
- hooks = list(HOOKS_DIR.glob(f"plugin-{name}-*.sh"))
1431
+ hooks = list(HOOKS_DIR.glob(f"plugin-{name}-*"))
1198
1432
  if hooks:
1199
1433
  print(f" Hooks: {', '.join(h.name for h in hooks)}")
1200
1434
  elif editor == "codex":
1201
- hooks = sorted(CODEX_HOOKS_DIR.glob(f"plugin-{name}-*.sh"))
1435
+ hooks = sorted(CODEX_HOOKS_DIR.glob(f"plugin-{name}-*"))
1202
1436
  if hooks:
1203
1437
  print(f" Hooks: {', '.join(h.name for h in hooks)}")
1204
1438
  if name == "memory-pack":
@@ -1250,8 +1484,8 @@ def _parse_editors(args: list[str]) -> tuple[list[str], list[str]]:
1250
1484
 
1251
1485
  def _cmd_install(args: list[str], editors: list[str]) -> None:
1252
1486
  if not args:
1253
- print("Usage: ai-toolkit plugin install [--editor claude|codex|all] <pack-name> [...]")
1254
- 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")
1255
1489
  sys.exit(1)
1256
1490
  names = [pack["name"] for pack in list_available()] if "--all" in args else args
1257
1491
  for editor in editors:
@@ -1269,8 +1503,8 @@ def _cmd_install(args: list[str], editors: list[str]) -> None:
1269
1503
 
1270
1504
  def _cmd_remove(args: list[str], editors: list[str]) -> None:
1271
1505
  if not args:
1272
- print("Usage: ai-toolkit plugin remove [--editor claude|codex|all] <pack-name> [...]")
1273
- 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")
1274
1508
  sys.exit(1)
1275
1509
  state = load_state()
1276
1510
  for editor in editors:
@@ -1286,8 +1520,8 @@ def _cmd_remove(args: list[str], editors: list[str]) -> None:
1286
1520
 
1287
1521
  def _cmd_update(args: list[str], editors: list[str]) -> None:
1288
1522
  if not args:
1289
- print("Usage: ai-toolkit plugin update [--editor claude|codex|all] <pack-name> [...]")
1290
- print(" ai-toolkit plugin update [--editor claude|codex|all] --all [--dry-run]")
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]")
1291
1525
  sys.exit(1)
1292
1526
 
1293
1527
  dry_run = "--dry-run" in args or "--list" in args
@@ -16,6 +16,7 @@ from __future__ import annotations
16
16
 
17
17
  import argparse
18
18
  import copy
19
+ import importlib
19
20
  import json
20
21
  import os
21
22
  import re
@@ -1324,6 +1325,28 @@ def _parse_args(argv: list[str]) -> argparse.Namespace:
1324
1325
  return args
1325
1326
 
1326
1327
 
1328
+ def _remove_editor_hook_configs(target: Path) -> None:
1329
+ """Strip toolkit hook entries from editor configs that have no other cleanup.
1330
+
1331
+ Best effort by design: a missing generator or an unreadable config must not
1332
+ abort an uninstall that has already removed the primary surfaces.
1333
+ """
1334
+ for module_name, label in (
1335
+ ("generate_cursor_hooks", "Cursor"),
1336
+ ("generate_gemini_hooks", "Gemini"),
1337
+ ):
1338
+ try:
1339
+ module = importlib.import_module(module_name)
1340
+ cleanup = getattr(module, "cleanup", None)
1341
+ if cleanup is None:
1342
+ continue
1343
+ cleanup(target)
1344
+ except (ImportError, OSError, RuntimeError, ValueError) as error:
1345
+ print(f" WARN could not clean {label} hooks: {error}")
1346
+ else:
1347
+ print(f" Cleaned: {label} hook entries")
1348
+
1349
+
1327
1350
  def main(argv: list[str] | None = None) -> None:
1328
1351
  args = _parse_args(sys.argv[1:] if argv is None else argv)
1329
1352
  explicit_target = args.target or args.legacy_target
@@ -1412,6 +1435,12 @@ def main(argv: list[str] | None = None) -> None:
1412
1435
  for surface in copilot:
1413
1436
  _preflight(target, claude, [], [surface])
1414
1437
  _remove_copilot(surface)
1438
+ # Cursor and Gemini hook configs were never cleaned: uninstall knew
1439
+ # only Codex and Copilot, so `.cursor/hooks.json` and the hooks block in
1440
+ # `.gemini/settings.json` survived an uninstall. Both cleanups strip
1441
+ # only entries tagged `ai-toolkit`, leaving user-authored and
1442
+ # plugin-pack entries and any unrelated settings in place.
1443
+ _remove_editor_hook_configs(target)
1415
1444
  if recovery_root is not None and recovery_components:
1416
1445
  # The recovery API preflights its complete tree before the first
1417
1446
  # unlink. A later I/O fault can still leave recovery partially