@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/CHANGELOG.md +89 -0
- package/README.md +12 -10
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/plugins/README.md +16 -4
- package/benchmarks/ecosystem-doctor-snapshot.json +8 -8
- package/bin/ai-toolkit.js +16 -1
- package/kb/history/completed/rtk-pack-integration-20260726.md +710 -0
- package/kb/history/completed/rtk-pack-retirement-20260727.md +168 -0
- package/kb/procedures/maintenance-sop.md +1 -1
- package/kb/procedures/post-release-testing-sop.md +217 -0
- package/kb/procedures/release-preparation-sop.md +20 -7
- package/kb/reference/cli-reference.md +1 -1
- package/kb/reference/plugin-pack-conventions.md +26 -2
- package/llms-full.txt +1161 -11
- package/llms.txt +3 -0
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/audit_skills.py +21 -0
- 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 +403 -49
- package/scripts/uninstall.py +29 -0
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": []},
|
|
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
|
-
|
|
275
|
-
|
|
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
|
|
284
|
-
|
|
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
|
-
|
|
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}
|
|
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}
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
935
|
-
remove_pack_claude(name, pack, pack_dir, keep_shared_assets=keep_shared_assets)
|
|
936
|
-
|
|
937
|
-
|
|
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
|
|
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
|
-
|
|
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}
|
|
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}
|
|
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
|
|
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
|
-
|
|
1212
|
-
|
|
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
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
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
|
|