@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/CHANGELOG.md +37 -0
- package/README.md +10 -8
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/plugins/README.md +5 -5
- package/benchmarks/ecosystem-doctor-snapshot.json +8 -8
- package/bin/ai-toolkit.js +1 -1
- package/kb/history/completed/rtk-pack-retirement-20260727.md +168 -0
- package/kb/procedures/post-release-testing-sop.md +217 -0
- package/kb/procedures/release-preparation-sop.md +12 -4
- package/kb/reference/plugin-pack-conventions.md +24 -15
- package/llms-full.txt +433 -304
- package/llms.txt +2 -1
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/audit_skills.py +1 -1
- package/scripts/generate_cursor_hooks.py +44 -0
- package/scripts/generate_gemini_hooks.py +44 -0
- package/scripts/install.py +14 -1
- package/scripts/plugin.py +270 -36
- package/scripts/uninstall.py +29 -0
- package/app/plugins/rtk-pack/README.md +0 -123
- package/app/plugins/rtk-pack/hooks/rewrite.sh +0 -79
- package/app/plugins/rtk-pack/plugin.json +0 -60
- package/app/plugins/rtk-pack/scripts/init.py +0 -252
- package/app/plugins/rtk-pack/scripts/status.py +0 -105
- package/kb/procedures/rtk-upstream-sync-sop.md +0 -279
- package/scripts/verify_rtk_binary.py +0 -335
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softspark/ai-toolkit",
|
|
3
|
-
"version": "4.
|
|
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",
|
package/scripts/audit_skills.py
CHANGED
|
@@ -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
|
|
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()
|
package/scripts/install.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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}
|
|
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}
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
970
|
-
remove_pack_claude(name, pack, pack_dir, keep_shared_assets=keep_shared_assets)
|
|
971
|
-
|
|
972
|
-
|
|
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}
|
|
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}
|
|
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
|
package/scripts/uninstall.py
CHANGED
|
@@ -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
|