@softspark/ai-toolkit 2.0.1 → 2.1.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 +49 -20
- package/app/ARCHITECTURE.md +1 -1
- package/app/plugins/README.md +6 -2
- package/app/skills/plugin-creator/SKILL.md +3 -4
- package/bin/ai-toolkit.js +20 -7
- package/kb/procedures/maintenance-sop.md +64 -16
- package/kb/procedures/release-preparation-sop.md +4 -2
- package/kb/procedures/release-verification-sop.md +15 -13
- package/kb/reference/architecture-overview.md +44 -5
- package/kb/reference/claude-ecosystem-expansion-foundations.md +4 -4
- package/kb/reference/codex-cli-compatibility.md +136 -0
- package/kb/reference/extension-api.md +23 -6
- package/kb/reference/global-install-model.md +62 -5
- package/kb/reference/mcp-editor-compatibility.md +62 -0
- package/kb/reference/mcp-templates.md +32 -6
- package/kb/reference/plugin-pack-conventions.md +22 -21
- package/kb/reference/skills-catalog.md +27 -5
- package/llms-full.txt +508 -84
- package/llms.txt +2 -0
- package/package.json +5 -4
- package/scripts/codex_skill_adapter.py +295 -0
- package/scripts/dir_rules_shared.py +46 -7
- package/scripts/generate_cline_rules.py +11 -2
- package/scripts/generate_codex.py +92 -0
- package/scripts/generate_codex_hooks.py +78 -0
- package/scripts/generate_codex_rules.py +51 -0
- package/scripts/install_steps/ai_tools.py +83 -4
- package/scripts/mcp_editors.py +340 -0
- package/scripts/mcp_manager.py +125 -13
- package/scripts/migrate.py +11 -27
- package/scripts/plugin.py +745 -301
- package/scripts/plugin_schema.py +16 -1
package/scripts/plugin.py
CHANGED
|
@@ -2,28 +2,27 @@
|
|
|
2
2
|
"""ai-toolkit plugin — install, remove, update, clean, and list plugin packs.
|
|
3
3
|
|
|
4
4
|
Usage:
|
|
5
|
-
plugin.py
|
|
6
|
-
plugin.py
|
|
7
|
-
plugin.py
|
|
8
|
-
plugin.py
|
|
9
|
-
plugin.py
|
|
10
|
-
plugin.py
|
|
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
|
|
11
13
|
plugin.py clean <pack-name> [--days N]
|
|
12
|
-
plugin.py list
|
|
13
|
-
plugin.py status
|
|
14
14
|
|
|
15
15
|
Actions:
|
|
16
|
-
install
|
|
17
|
-
remove Remove plugin hooks/scripts
|
|
18
|
-
update Re-install plugin (remove + install)
|
|
19
|
-
clean Prune old data (e.g. memory-pack observations older than --days N
|
|
20
|
-
list Show available plugin packs with status
|
|
21
|
-
status Show
|
|
16
|
+
install Install a plugin pack for one or more runtimes
|
|
17
|
+
remove Remove plugin hooks/rules/scripts for one or more runtimes
|
|
18
|
+
update Re-install plugin pack (remove + install)
|
|
19
|
+
clean Prune old data (e.g. memory-pack observations older than --days N)
|
|
20
|
+
list Show available plugin packs with install status by runtime
|
|
21
|
+
status Show installed packs with runtime-specific details
|
|
22
22
|
"""
|
|
23
23
|
from __future__ import annotations
|
|
24
24
|
|
|
25
25
|
import json
|
|
26
|
-
import os
|
|
27
26
|
import shutil
|
|
28
27
|
import sqlite3 as sqlite
|
|
29
28
|
import subprocess
|
|
@@ -31,38 +30,85 @@ import sys
|
|
|
31
30
|
from pathlib import Path
|
|
32
31
|
|
|
33
32
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
34
|
-
from _common import
|
|
33
|
+
from _common import app_dir, inject_section, inject_rule, remove_rule_section
|
|
34
|
+
from codex_skill_adapter import cleanup_codex_skills, sync_codex_skill
|
|
35
|
+
from generate_codex_hooks import generate as generate_codex_hooks
|
|
36
|
+
from generate_codex_rules import generate as generate_codex_rules
|
|
37
|
+
from install_steps.ai_tools import inject_with_rules
|
|
38
|
+
from paths import HOOKS_DIR as _HOOKS_DIR
|
|
39
|
+
from paths import RULES_DIR, TOOLKIT_DATA_DIR
|
|
35
40
|
from plugin_schema import resolve_hook_event, validate_manifest, validate_references
|
|
36
41
|
|
|
37
42
|
|
|
38
|
-
from paths import TOOLKIT_DATA_DIR, HOOKS_DIR as _HOOKS_DIR
|
|
39
|
-
|
|
40
43
|
PLUGINS_DIR = app_dir / "plugins"
|
|
41
44
|
CLAUDE_DIR = Path.home() / ".claude"
|
|
45
|
+
CODEX_ROOT = Path.home()
|
|
42
46
|
HOOKS_DIR = _HOOKS_DIR
|
|
43
47
|
PLUGINS_STATE_FILE = TOOLKIT_DATA_DIR / "plugins.json"
|
|
48
|
+
MEMORY_DB = TOOLKIT_DATA_DIR / "memory.db"
|
|
49
|
+
|
|
50
|
+
VALID_EDITORS = ("claude", "codex")
|
|
51
|
+
SUPPORTED_CODEX_HOOK_EVENTS = frozenset({
|
|
52
|
+
"SessionStart",
|
|
53
|
+
"PreToolUse",
|
|
54
|
+
"PostToolUse",
|
|
55
|
+
"UserPromptSubmit",
|
|
56
|
+
"Stop",
|
|
57
|
+
})
|
|
44
58
|
|
|
45
59
|
|
|
46
60
|
# ---------------------------------------------------------------------------
|
|
47
61
|
# State management
|
|
48
62
|
# ---------------------------------------------------------------------------
|
|
49
63
|
|
|
64
|
+
def _empty_state() -> dict:
|
|
65
|
+
return {
|
|
66
|
+
"targets": {
|
|
67
|
+
"claude": {"installed": []},
|
|
68
|
+
"codex": {"installed": []},
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
50
73
|
def load_state() -> dict:
|
|
51
|
-
"""Load installed plugins state."""
|
|
74
|
+
"""Load installed plugins state with backwards compatibility."""
|
|
75
|
+
state = _empty_state()
|
|
52
76
|
if PLUGINS_STATE_FILE.is_file():
|
|
53
77
|
try:
|
|
54
|
-
with open(PLUGINS_STATE_FILE) as f:
|
|
55
|
-
|
|
78
|
+
with open(PLUGINS_STATE_FILE, encoding="utf-8") as f:
|
|
79
|
+
raw = json.load(f)
|
|
56
80
|
except (json.JSONDecodeError, OSError):
|
|
57
|
-
|
|
58
|
-
|
|
81
|
+
raw = {}
|
|
82
|
+
|
|
83
|
+
if isinstance(raw, dict):
|
|
84
|
+
if isinstance(raw.get("installed"), list):
|
|
85
|
+
# Legacy format: Claude-only installs.
|
|
86
|
+
state["targets"]["claude"]["installed"] = sorted(set(raw["installed"]))
|
|
87
|
+
|
|
88
|
+
targets = raw.get("targets", {})
|
|
89
|
+
if isinstance(targets, dict):
|
|
90
|
+
for editor in VALID_EDITORS:
|
|
91
|
+
installed = targets.get(editor, {}).get("installed", [])
|
|
92
|
+
if isinstance(installed, list):
|
|
93
|
+
state["targets"][editor]["installed"] = sorted(set(installed))
|
|
94
|
+
return state
|
|
59
95
|
|
|
60
96
|
|
|
61
97
|
def save_state(state: dict) -> None:
|
|
62
98
|
"""Save installed plugins state."""
|
|
63
99
|
PLUGINS_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
-
with open(PLUGINS_STATE_FILE, "w") as f:
|
|
100
|
+
with open(PLUGINS_STATE_FILE, "w", encoding="utf-8") as f:
|
|
65
101
|
json.dump(state, f, indent=2)
|
|
102
|
+
f.write("\n")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _installed_for(state: dict, editor: str) -> list[str]:
|
|
106
|
+
return list(state.get("targets", {}).get(editor, {}).get("installed", []))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _set_installed(state: dict, editor: str, names: list[str]) -> None:
|
|
110
|
+
state.setdefault("targets", {}).setdefault(editor, {})
|
|
111
|
+
state["targets"][editor]["installed"] = sorted(set(names))
|
|
66
112
|
|
|
67
113
|
|
|
68
114
|
# ---------------------------------------------------------------------------
|
|
@@ -79,7 +125,7 @@ def list_available() -> list[dict]:
|
|
|
79
125
|
if not manifest.is_file():
|
|
80
126
|
continue
|
|
81
127
|
try:
|
|
82
|
-
with open(manifest) as f:
|
|
128
|
+
with open(manifest, encoding="utf-8") as f:
|
|
83
129
|
data = json.load(f)
|
|
84
130
|
data["_dir"] = str(d)
|
|
85
131
|
packs.append(data)
|
|
@@ -96,64 +142,107 @@ def find_pack(name: str) -> dict | None:
|
|
|
96
142
|
return None
|
|
97
143
|
|
|
98
144
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
145
|
+
def _resolve_skill_source(pack_dir: Path, skill: str) -> Path | None:
|
|
146
|
+
core = app_dir / "skills" / skill / "SKILL.md"
|
|
147
|
+
plugin = pack_dir / "skills" / skill / "SKILL.md"
|
|
148
|
+
if core.is_file():
|
|
149
|
+
return core.parent
|
|
150
|
+
if plugin.is_file():
|
|
151
|
+
return plugin.parent
|
|
152
|
+
return None
|
|
102
153
|
|
|
103
|
-
def _link_agents(includes: dict, pack_dir: Path, installed_items: list[str]) -> None:
|
|
104
|
-
"""Verify and link referenced agents from a plugin pack."""
|
|
105
|
-
for agent in includes.get("agents", []):
|
|
106
|
-
agent_file = CLAUDE_DIR / "agents" / f"{agent}.md"
|
|
107
|
-
source_file = app_dir / "agents" / f"{agent}.md"
|
|
108
|
-
if agent_file.exists() or agent_file.is_symlink():
|
|
109
|
-
print(f" OK agent: {agent}")
|
|
110
|
-
elif source_file.is_file():
|
|
111
|
-
agent_file.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
-
agent_file.symlink_to(source_file)
|
|
113
|
-
print(f" Linked agent: {agent}")
|
|
114
|
-
installed_items.append(f"agent:{agent}")
|
|
115
|
-
else:
|
|
116
|
-
print(f" WARN agent not found: {agent}")
|
|
117
154
|
|
|
155
|
+
def _resolve_rule_source(pack_dir: Path, rule_name: str) -> tuple[Path, bool] | None:
|
|
156
|
+
candidates = [
|
|
157
|
+
(pack_dir / f"{rule_name}.md", False),
|
|
158
|
+
(pack_dir / "rules" / f"{rule_name}.md", False),
|
|
159
|
+
(pack_dir / rule_name, False),
|
|
160
|
+
(app_dir / "rules" / f"{rule_name}.md", True),
|
|
161
|
+
(app_dir / "rules" / rule_name, True),
|
|
162
|
+
]
|
|
163
|
+
for path, is_core in candidates:
|
|
164
|
+
if path.is_file():
|
|
165
|
+
return path, is_core
|
|
166
|
+
return None
|
|
118
167
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
168
|
+
|
|
169
|
+
def _resolve_hook_source(pack_dir: Path, hook_ref: str) -> tuple[Path, bool] | None:
|
|
170
|
+
base = Path(hook_ref).name
|
|
171
|
+
candidates = [
|
|
172
|
+
(pack_dir / hook_ref, False),
|
|
173
|
+
(pack_dir / "hooks" / base, False),
|
|
174
|
+
(app_dir / "hooks" / base, True),
|
|
175
|
+
]
|
|
176
|
+
for path, is_core in candidates:
|
|
177
|
+
if path.is_file():
|
|
178
|
+
return path, is_core
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _resolve_pack_hooks(pack: dict, pack_dir: Path) -> list[dict]:
|
|
183
|
+
specs: list[dict] = []
|
|
184
|
+
seen: set[str] = set()
|
|
185
|
+
for hook_ref in pack.get("includes", {}).get("hooks", []):
|
|
186
|
+
resolved = _resolve_hook_source(pack_dir, hook_ref)
|
|
187
|
+
if not resolved:
|
|
188
|
+
print(f" WARN hook not found: {hook_ref}")
|
|
189
|
+
continue
|
|
190
|
+
source, is_core = resolved
|
|
191
|
+
hook_name = source.name
|
|
192
|
+
if hook_name in seen:
|
|
193
|
+
continue
|
|
194
|
+
event = resolve_hook_event(hook_name, pack)
|
|
195
|
+
if not event:
|
|
196
|
+
print(f" WARN could not infer hook event for: {hook_name}")
|
|
197
|
+
continue
|
|
198
|
+
specs.append({
|
|
199
|
+
"ref": hook_ref,
|
|
200
|
+
"name": hook_name,
|
|
201
|
+
"event": event,
|
|
202
|
+
"source": source,
|
|
203
|
+
"is_core": is_core,
|
|
204
|
+
})
|
|
205
|
+
seen.add(hook_name)
|
|
206
|
+
return specs
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _resolve_pack_rules(pack: dict, pack_dir: Path) -> list[dict]:
|
|
210
|
+
specs: list[dict] = []
|
|
211
|
+
for rule_name in pack.get("includes", {}).get("rules", []):
|
|
212
|
+
resolved = _resolve_rule_source(pack_dir, rule_name)
|
|
213
|
+
if not resolved:
|
|
214
|
+
print(f" WARN rule not found: {rule_name}")
|
|
215
|
+
continue
|
|
216
|
+
source, is_core = resolved
|
|
217
|
+
specs.append({
|
|
218
|
+
"name": source.stem,
|
|
219
|
+
"source": source,
|
|
220
|
+
"is_core": is_core,
|
|
221
|
+
})
|
|
222
|
+
return specs
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _has_core_claude_rule(rule_name: str) -> bool:
|
|
226
|
+
return (app_dir / "rules" / f"{rule_name}.md").is_file()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
# Shared installers
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
def _ensure_core_hook_scripts() -> None:
|
|
234
|
+
"""Ensure canonical hook scripts exist in ~/.softspark/ai-toolkit/hooks/."""
|
|
235
|
+
hooks_src = app_dir / "hooks"
|
|
236
|
+
if not hooks_src.is_dir():
|
|
146
237
|
return
|
|
147
238
|
HOOKS_DIR.mkdir(parents=True, exist_ok=True)
|
|
148
|
-
for hook_file in sorted(
|
|
149
|
-
|
|
150
|
-
shutil.copy2(hook_file,
|
|
151
|
-
|
|
152
|
-
print(f" Copied hook: {hook_file.name} -> {dest.name}")
|
|
153
|
-
installed_items.append(f"hook:{dest.name}")
|
|
239
|
+
for hook_file in sorted(hooks_src.glob("*.sh")):
|
|
240
|
+
dst = HOOKS_DIR / hook_file.name
|
|
241
|
+
shutil.copy2(hook_file, dst)
|
|
242
|
+
dst.chmod(dst.stat().st_mode | 0o111)
|
|
154
243
|
|
|
155
244
|
|
|
156
|
-
def
|
|
245
|
+
def _copy_plugin_scripts(name: str, pack_dir: Path, installed_items: list[str]) -> None:
|
|
157
246
|
"""Copy plugin-specific scripts and run init if present."""
|
|
158
247
|
plugin_scripts_dir = pack_dir / "scripts"
|
|
159
248
|
if not plugin_scripts_dir.is_dir():
|
|
@@ -174,202 +263,510 @@ def _copy_scripts(name: str, pack_dir: Path, installed_items: list[str]) -> None
|
|
|
174
263
|
if init_script.is_file():
|
|
175
264
|
result = subprocess.run(
|
|
176
265
|
["python3", str(init_script)],
|
|
177
|
-
capture_output=True,
|
|
266
|
+
capture_output=True,
|
|
267
|
+
text=True,
|
|
178
268
|
)
|
|
179
|
-
if result.returncode == 0:
|
|
269
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
180
270
|
print(f" Init: {result.stdout.strip()}")
|
|
181
|
-
|
|
271
|
+
elif result.returncode != 0 and result.stderr.strip():
|
|
182
272
|
print(f" WARN init failed: {result.stderr.strip()}")
|
|
183
273
|
|
|
184
274
|
|
|
185
|
-
def
|
|
186
|
-
"""
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
275
|
+
def _copy_plugin_hook_scripts(name: str, hook_specs: list[dict], installed_items: list[str]) -> None:
|
|
276
|
+
"""Copy plugin-provided hook scripts into shared toolkit storage."""
|
|
277
|
+
if not hook_specs:
|
|
278
|
+
return
|
|
279
|
+
HOOKS_DIR.mkdir(parents=True, exist_ok=True)
|
|
280
|
+
for spec in hook_specs:
|
|
281
|
+
if spec["is_core"]:
|
|
282
|
+
continue
|
|
283
|
+
dest = HOOKS_DIR / f"plugin-{name}-{spec['name']}"
|
|
284
|
+
shutil.copy2(spec["source"], dest)
|
|
285
|
+
dest.chmod(dest.stat().st_mode | 0o111)
|
|
286
|
+
print(f" Copied hook: {spec['name']} -> {dest.name}")
|
|
287
|
+
installed_items.append(f"hook:{dest.name}")
|
|
196
288
|
|
|
197
|
-
print(f" Installing: {name} ({pack.get('description', '')})")
|
|
198
289
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
290
|
+
def _plugin_hook_command(name: str, spec: dict) -> str:
|
|
291
|
+
if spec["is_core"]:
|
|
292
|
+
return f"\"$HOME/.softspark/ai-toolkit/hooks/{spec['name']}\""
|
|
293
|
+
return f"\"$HOME/.softspark/ai-toolkit/hooks/plugin-{name}-{spec['name']}\""
|
|
203
294
|
|
|
204
|
-
plugin_hooks_dir = pack_dir / "hooks"
|
|
205
|
-
if plugin_hooks_dir.is_dir() and any(plugin_hooks_dir.glob("*.sh")):
|
|
206
|
-
_inject_plugin_hooks(name, pack_dir)
|
|
207
295
|
|
|
208
|
-
|
|
209
|
-
if
|
|
210
|
-
|
|
211
|
-
|
|
296
|
+
def _load_json(path: Path, default: dict) -> dict:
|
|
297
|
+
if not path.is_file():
|
|
298
|
+
return json.loads(json.dumps(default))
|
|
299
|
+
try:
|
|
300
|
+
with open(path, encoding="utf-8") as f:
|
|
301
|
+
data = json.load(f)
|
|
302
|
+
except (json.JSONDecodeError, OSError):
|
|
303
|
+
return json.loads(json.dumps(default))
|
|
304
|
+
if isinstance(data, dict):
|
|
305
|
+
return data
|
|
306
|
+
return json.loads(json.dumps(default))
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _write_json(path: Path, data: dict) -> None:
|
|
310
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
311
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
312
|
+
json.dump(data, f, indent=4)
|
|
313
|
+
f.write("\n")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _load_core_hook_matchers() -> dict[str, str]:
|
|
317
|
+
hooks_json = app_dir / "hooks.json"
|
|
318
|
+
if not hooks_json.is_file():
|
|
319
|
+
return {}
|
|
320
|
+
data = _load_json(hooks_json, {"hooks": {}})
|
|
321
|
+
mapping: dict[str, str] = {}
|
|
322
|
+
for entries in data.get("hooks", {}).values():
|
|
323
|
+
if not isinstance(entries, list):
|
|
324
|
+
continue
|
|
325
|
+
for entry in entries:
|
|
326
|
+
if not isinstance(entry, dict):
|
|
327
|
+
continue
|
|
328
|
+
matcher = entry.get("matcher", "")
|
|
329
|
+
for hook in entry.get("hooks", []):
|
|
330
|
+
if not isinstance(hook, dict):
|
|
331
|
+
continue
|
|
332
|
+
command = hook.get("command", "")
|
|
333
|
+
base = Path(command.replace("\"", "")).name
|
|
334
|
+
if base and base not in mapping:
|
|
335
|
+
mapping[base] = matcher
|
|
336
|
+
return mapping
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
CORE_HOOK_MATCHERS = _load_core_hook_matchers()
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _hook_already_present(hooks_data: dict, hook_name: str) -> bool:
|
|
343
|
+
for entries in hooks_data.get("hooks", {}).values():
|
|
344
|
+
if not isinstance(entries, list):
|
|
345
|
+
continue
|
|
346
|
+
for entry in entries:
|
|
347
|
+
for hook in entry.get("hooks", []):
|
|
348
|
+
command = hook.get("command", "")
|
|
349
|
+
if Path(command.replace("\"", "")).name == hook_name:
|
|
350
|
+
return True
|
|
351
|
+
return False
|
|
212
352
|
|
|
213
|
-
print(f" Done: {name} ({len(installed_items)} items)")
|
|
214
|
-
return True
|
|
215
353
|
|
|
354
|
+
# ---------------------------------------------------------------------------
|
|
355
|
+
# Claude runtime
|
|
356
|
+
# ---------------------------------------------------------------------------
|
|
216
357
|
|
|
217
|
-
def
|
|
218
|
-
|
|
358
|
+
def _ensure_claude_settings() -> Path:
|
|
359
|
+
CLAUDE_DIR.mkdir(parents=True, exist_ok=True)
|
|
219
360
|
settings_path = CLAUDE_DIR / "settings.json"
|
|
220
361
|
if not settings_path.is_file():
|
|
221
|
-
|
|
362
|
+
_write_json(settings_path, {"hooks": {}, "env": {}})
|
|
363
|
+
return settings_path
|
|
222
364
|
|
|
223
|
-
try:
|
|
224
|
-
with open(settings_path) as f:
|
|
225
|
-
settings = json.load(f)
|
|
226
|
-
except (json.JSONDecodeError, OSError):
|
|
227
|
-
return
|
|
228
365
|
|
|
366
|
+
def _install_claude_skills(pack: dict, pack_dir: Path, installed_items: list[str]) -> None:
|
|
367
|
+
for skill in pack.get("includes", {}).get("skills", []):
|
|
368
|
+
skill_dir = CLAUDE_DIR / "skills" / skill
|
|
369
|
+
source_dir = _resolve_skill_source(pack_dir, skill)
|
|
370
|
+
if skill_dir.exists() or skill_dir.is_symlink():
|
|
371
|
+
print(f" OK skill: {skill}")
|
|
372
|
+
elif source_dir:
|
|
373
|
+
skill_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
374
|
+
skill_dir.symlink_to(source_dir)
|
|
375
|
+
print(f" Linked skill: {skill}")
|
|
376
|
+
installed_items.append(f"skill:{skill}")
|
|
377
|
+
else:
|
|
378
|
+
print(f" WARN skill not found: {skill}")
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _install_claude_agents(pack: dict, installed_items: list[str]) -> None:
|
|
382
|
+
for agent in pack.get("includes", {}).get("agents", []):
|
|
383
|
+
agent_file = CLAUDE_DIR / "agents" / f"{agent}.md"
|
|
384
|
+
source_file = app_dir / "agents" / f"{agent}.md"
|
|
385
|
+
if agent_file.exists() or agent_file.is_symlink():
|
|
386
|
+
print(f" OK agent: {agent}")
|
|
387
|
+
elif source_file.is_file():
|
|
388
|
+
agent_file.parent.mkdir(parents=True, exist_ok=True)
|
|
389
|
+
agent_file.symlink_to(source_file)
|
|
390
|
+
print(f" Linked agent: {agent}")
|
|
391
|
+
installed_items.append(f"agent:{agent}")
|
|
392
|
+
else:
|
|
393
|
+
print(f" WARN agent not found: {agent}")
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _merge_claude_hooks(name: str, hook_specs: list[dict]) -> None:
|
|
397
|
+
settings_path = _ensure_claude_settings()
|
|
398
|
+
settings = _load_json(settings_path, {"hooks": {}, "env": {}})
|
|
229
399
|
hooks = settings.setdefault("hooks", {})
|
|
230
|
-
|
|
400
|
+
source_tag = f"ai-toolkit-plugin-{name}"
|
|
231
401
|
|
|
232
|
-
for
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if not event:
|
|
402
|
+
for spec in hook_specs:
|
|
403
|
+
if spec["is_core"]:
|
|
404
|
+
# Base Claude install already owns core hooks.
|
|
236
405
|
continue
|
|
237
|
-
|
|
238
|
-
dest_path = HOOKS_DIR / f"plugin-{name}-{hook_file.name}"
|
|
239
406
|
entry = {
|
|
240
|
-
"_source":
|
|
241
|
-
"matcher": "",
|
|
407
|
+
"_source": source_tag,
|
|
408
|
+
"matcher": CORE_HOOK_MATCHERS.get(spec["name"], ""),
|
|
242
409
|
"hooks": [
|
|
243
410
|
{
|
|
244
411
|
"type": "command",
|
|
245
|
-
"command":
|
|
412
|
+
"command": _plugin_hook_command(name, spec),
|
|
246
413
|
}
|
|
247
414
|
],
|
|
248
415
|
}
|
|
249
|
-
|
|
250
|
-
event_hooks =
|
|
251
|
-
# Remove old entries from this plugin
|
|
252
|
-
event_hooks = [h for h in event_hooks if h.get("_source") != f"ai-toolkit-plugin-{name}"]
|
|
416
|
+
event_hooks = hooks.setdefault(spec["event"], [])
|
|
417
|
+
event_hooks = [h for h in event_hooks if h.get("_source") != source_tag]
|
|
253
418
|
event_hooks.append(entry)
|
|
254
|
-
hooks[event] = event_hooks
|
|
419
|
+
hooks[spec["event"]] = event_hooks
|
|
255
420
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
421
|
+
_write_json(settings_path, settings)
|
|
422
|
+
print(" Merged hooks into ~/.claude/settings.json")
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _strip_claude_hooks(name: str) -> None:
|
|
426
|
+
settings_path = CLAUDE_DIR / "settings.json"
|
|
427
|
+
if not settings_path.is_file():
|
|
428
|
+
return
|
|
429
|
+
|
|
430
|
+
settings = _load_json(settings_path, {"hooks": {}})
|
|
431
|
+
hooks = settings.get("hooks", {})
|
|
432
|
+
source_tag = f"ai-toolkit-plugin-{name}"
|
|
433
|
+
changed = False
|
|
434
|
+
|
|
435
|
+
for event in list(hooks.keys()):
|
|
436
|
+
original = hooks[event]
|
|
437
|
+
filtered = [h for h in original if h.get("_source") != source_tag]
|
|
438
|
+
if len(filtered) != len(original):
|
|
439
|
+
hooks[event] = filtered
|
|
440
|
+
changed = True
|
|
441
|
+
if not hooks[event]:
|
|
442
|
+
del hooks[event]
|
|
259
443
|
|
|
444
|
+
if changed:
|
|
445
|
+
_write_json(settings_path, settings)
|
|
446
|
+
print(" Stripped hooks from ~/.claude/settings.json")
|
|
260
447
|
|
|
261
|
-
def _guess_event(hook_filename: str, pack_name: str) -> str:
|
|
262
|
-
"""Map hook filename to Claude Code event.
|
|
263
448
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
449
|
+
def _install_claude_rules(name: str, rule_specs: list[dict]) -> None:
|
|
450
|
+
for spec in rule_specs:
|
|
451
|
+
if spec["is_core"] and _has_core_claude_rule(spec["name"]):
|
|
452
|
+
print(f" OK rule (core install): {spec['name']}")
|
|
453
|
+
continue
|
|
454
|
+
section = f"plugin-{name}-{spec['name']}"
|
|
455
|
+
inject_section(spec["source"], CLAUDE_DIR / "CLAUDE.md", section)
|
|
456
|
+
print(f" Injected rule: {spec['name']} -> ~/.claude/CLAUDE.md")
|
|
272
457
|
|
|
273
458
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
459
|
+
def _remove_claude_rules(name: str, rule_specs: list[dict]) -> None:
|
|
460
|
+
for spec in rule_specs:
|
|
461
|
+
if spec["is_core"] and _has_core_claude_rule(spec["name"]):
|
|
462
|
+
continue
|
|
463
|
+
section = f"plugin-{name}-{spec['name']}"
|
|
464
|
+
remove_rule_section(section, Path.home())
|
|
277
465
|
|
|
278
|
-
def remove_pack(name: str) -> bool:
|
|
279
|
-
"""Remove a plugin pack. Returns True if successful."""
|
|
280
|
-
state = load_state()
|
|
281
|
-
if name not in state["installed"]:
|
|
282
|
-
print(f" Plugin '{name}' is not installed")
|
|
283
|
-
return False
|
|
284
466
|
|
|
285
|
-
|
|
467
|
+
def install_pack_claude(name: str, pack: dict, pack_dir: Path) -> bool:
|
|
468
|
+
installed_items: list[str] = []
|
|
469
|
+
hook_specs = _resolve_pack_hooks(pack, pack_dir)
|
|
470
|
+
rule_specs = _resolve_pack_rules(pack, pack_dir)
|
|
471
|
+
|
|
472
|
+
_ensure_core_hook_scripts()
|
|
473
|
+
_install_claude_agents(pack, installed_items)
|
|
474
|
+
_install_claude_skills(pack, pack_dir, installed_items)
|
|
475
|
+
_copy_plugin_hook_scripts(name, hook_specs, installed_items)
|
|
476
|
+
_copy_plugin_scripts(name, pack_dir, installed_items)
|
|
477
|
+
_install_claude_rules(name, rule_specs)
|
|
478
|
+
if any(not spec["is_core"] for spec in hook_specs):
|
|
479
|
+
_merge_claude_hooks(name, hook_specs)
|
|
480
|
+
|
|
481
|
+
print(f" Done: {name} for claude ({len(installed_items)} file items)")
|
|
482
|
+
return True
|
|
286
483
|
|
|
287
|
-
# 1. Remove plugin hooks from ~/.softspark/ai-toolkit/hooks/
|
|
288
|
-
removed = 0
|
|
289
|
-
for hook in HOOKS_DIR.glob(f"plugin-{name}-*.sh"):
|
|
290
|
-
hook.unlink()
|
|
291
|
-
print(f" Removed hook: {hook.name}")
|
|
292
|
-
removed += 1
|
|
293
484
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
shutil.rmtree(scripts_dir)
|
|
298
|
-
print(f" Removed scripts: {scripts_dir}")
|
|
299
|
-
removed += 1
|
|
485
|
+
def remove_pack_claude(name: str, pack: dict, pack_dir: Path, *, keep_shared_assets: bool) -> bool:
|
|
486
|
+
hook_specs = _resolve_pack_hooks(pack, pack_dir)
|
|
487
|
+
rule_specs = _resolve_pack_rules(pack, pack_dir)
|
|
300
488
|
|
|
301
|
-
|
|
302
|
-
|
|
489
|
+
if not keep_shared_assets:
|
|
490
|
+
for hook in HOOKS_DIR.glob(f"plugin-{name}-*.sh"):
|
|
491
|
+
hook.unlink()
|
|
492
|
+
print(f" Removed hook: {hook.name}")
|
|
303
493
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
494
|
+
scripts_dir = TOOLKIT_DATA_DIR / "plugin-scripts" / name
|
|
495
|
+
if scripts_dir.is_dir():
|
|
496
|
+
shutil.rmtree(scripts_dir)
|
|
497
|
+
print(f" Removed scripts: {scripts_dir}")
|
|
307
498
|
|
|
308
|
-
|
|
499
|
+
if any(not spec["is_core"] for spec in hook_specs):
|
|
500
|
+
_strip_claude_hooks(name)
|
|
501
|
+
_remove_claude_rules(name, rule_specs)
|
|
502
|
+
|
|
503
|
+
print(f" Done: removed {name} from claude")
|
|
309
504
|
return True
|
|
310
505
|
|
|
311
506
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
507
|
+
# ---------------------------------------------------------------------------
|
|
508
|
+
# Codex runtime
|
|
509
|
+
# ---------------------------------------------------------------------------
|
|
510
|
+
|
|
511
|
+
def _install_all_codex_skills(target_root: Path) -> None:
|
|
512
|
+
skills_src = app_dir / "skills"
|
|
513
|
+
skills_dst = target_root / ".agents" / "skills"
|
|
514
|
+
skills_dst.mkdir(parents=True, exist_ok=True)
|
|
515
|
+
for skill_dir in sorted(skills_src.iterdir()):
|
|
516
|
+
if not skill_dir.is_dir() or skill_dir.name.startswith("_"):
|
|
517
|
+
continue
|
|
518
|
+
skill_md = skill_dir / "SKILL.md"
|
|
519
|
+
if not skill_md.is_file():
|
|
520
|
+
continue
|
|
521
|
+
sync_codex_skill(skill_dir, skills_dst)
|
|
522
|
+
cleanup_codex_skills(skills_dst, skills_src)
|
|
317
523
|
|
|
318
|
-
try:
|
|
319
|
-
with open(settings_path) as f:
|
|
320
|
-
settings = json.load(f)
|
|
321
|
-
except (json.JSONDecodeError, OSError):
|
|
322
|
-
return
|
|
323
524
|
|
|
525
|
+
def _install_codex_extra_skills(pack: dict, pack_dir: Path) -> None:
|
|
526
|
+
skills_dst = CODEX_ROOT / ".agents" / "skills"
|
|
527
|
+
skills_dst.mkdir(parents=True, exist_ok=True)
|
|
528
|
+
for skill in pack.get("includes", {}).get("skills", []):
|
|
529
|
+
source_dir = _resolve_skill_source(pack_dir, skill)
|
|
530
|
+
if not source_dir:
|
|
531
|
+
print(f" WARN skill not found: {skill}")
|
|
532
|
+
continue
|
|
533
|
+
sync_codex_skill(source_dir, skills_dst)
|
|
534
|
+
print(f" Ensured Codex skill: {skill}")
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _install_codex_base() -> None:
|
|
538
|
+
_ensure_core_hook_scripts()
|
|
539
|
+
inject_with_rules("generate_codex.py", CODEX_ROOT / "AGENTS.md", RULES_DIR)
|
|
540
|
+
generate_codex_rules(CODEX_ROOT, rules_dir=RULES_DIR)
|
|
541
|
+
hooks_path = CODEX_ROOT / ".codex" / "hooks.json"
|
|
542
|
+
existing = _load_json(hooks_path, {"hooks": {}})
|
|
543
|
+
plugin_entries: dict[str, list[dict]] = {}
|
|
544
|
+
for event, entries in existing.get("hooks", {}).items():
|
|
545
|
+
kept = [entry for entry in entries if str(entry.get("_source", "")).startswith("ai-toolkit-plugin-")]
|
|
546
|
+
if kept:
|
|
547
|
+
plugin_entries[event] = kept
|
|
548
|
+
|
|
549
|
+
generate_codex_hooks(CODEX_ROOT)
|
|
550
|
+
|
|
551
|
+
if plugin_entries:
|
|
552
|
+
refreshed = _load_json(hooks_path, {"hooks": {}})
|
|
553
|
+
bucket = refreshed.setdefault("hooks", {})
|
|
554
|
+
for event, entries in plugin_entries.items():
|
|
555
|
+
bucket.setdefault(event, [])
|
|
556
|
+
bucket[event].extend(entries)
|
|
557
|
+
_write_json(hooks_path, refreshed)
|
|
558
|
+
_install_all_codex_skills(CODEX_ROOT)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _ensure_codex_hooks_file() -> Path:
|
|
562
|
+
hooks_path = CODEX_ROOT / ".codex" / "hooks.json"
|
|
563
|
+
if not hooks_path.is_file():
|
|
564
|
+
generate_codex_hooks(CODEX_ROOT)
|
|
565
|
+
return hooks_path
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _codex_matcher(spec: dict) -> str:
|
|
569
|
+
if spec["event"] in {"PreToolUse", "PostToolUse"}:
|
|
570
|
+
return "Bash"
|
|
571
|
+
if spec["event"] == "SessionStart":
|
|
572
|
+
return "startup|resume"
|
|
573
|
+
return CORE_HOOK_MATCHERS.get(spec["name"], "")
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _merge_codex_hooks(name: str, hook_specs: list[dict]) -> None:
|
|
577
|
+
hooks_path = _ensure_codex_hooks_file()
|
|
578
|
+
data = _load_json(hooks_path, {"hooks": {}})
|
|
579
|
+
bucket = data.setdefault("hooks", {})
|
|
580
|
+
source_tag = f"ai-toolkit-plugin-{name}"
|
|
581
|
+
|
|
582
|
+
for spec in hook_specs:
|
|
583
|
+
if spec["event"] not in SUPPORTED_CODEX_HOOK_EVENTS:
|
|
584
|
+
print(f" Skipped Codex hook (unsupported event): {spec['name']} -> {spec['event']}")
|
|
585
|
+
continue
|
|
586
|
+
if spec["is_core"] and _hook_already_present(data, spec["name"]):
|
|
587
|
+
print(f" OK Codex hook (base install): {spec['name']}")
|
|
588
|
+
continue
|
|
589
|
+
entry = {
|
|
590
|
+
"_source": source_tag,
|
|
591
|
+
"hooks": [
|
|
592
|
+
{
|
|
593
|
+
"type": "command",
|
|
594
|
+
"command": _plugin_hook_command(name, spec),
|
|
595
|
+
}
|
|
596
|
+
],
|
|
597
|
+
}
|
|
598
|
+
matcher = _codex_matcher(spec)
|
|
599
|
+
if matcher:
|
|
600
|
+
entry["matcher"] = matcher
|
|
601
|
+
|
|
602
|
+
event_hooks = bucket.setdefault(spec["event"], [])
|
|
603
|
+
event_hooks = [h for h in event_hooks if h.get("_source") != source_tag]
|
|
604
|
+
event_hooks.append(entry)
|
|
605
|
+
bucket[spec["event"]] = event_hooks
|
|
606
|
+
|
|
607
|
+
_write_json(hooks_path, data)
|
|
608
|
+
print(" Merged hooks into ~/.codex/hooks.json")
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _strip_codex_hooks(name: str) -> None:
|
|
612
|
+
hooks_path = CODEX_ROOT / ".codex" / "hooks.json"
|
|
613
|
+
if not hooks_path.is_file():
|
|
614
|
+
return
|
|
615
|
+
data = _load_json(hooks_path, {"hooks": {}})
|
|
616
|
+
bucket = data.get("hooks", {})
|
|
324
617
|
source_tag = f"ai-toolkit-plugin-{name}"
|
|
325
|
-
hooks = settings.get("hooks", {})
|
|
326
618
|
changed = False
|
|
327
619
|
|
|
328
|
-
for event in list(
|
|
329
|
-
original =
|
|
620
|
+
for event in list(bucket.keys()):
|
|
621
|
+
original = bucket[event]
|
|
330
622
|
filtered = [h for h in original if h.get("_source") != source_tag]
|
|
331
623
|
if len(filtered) != len(original):
|
|
332
|
-
|
|
624
|
+
bucket[event] = filtered
|
|
333
625
|
changed = True
|
|
334
|
-
if not
|
|
335
|
-
del
|
|
626
|
+
if not bucket[event]:
|
|
627
|
+
del bucket[event]
|
|
336
628
|
|
|
337
629
|
if changed:
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
630
|
+
_write_json(hooks_path, data)
|
|
631
|
+
print(" Stripped hooks from ~/.codex/hooks.json")
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _install_codex_rules(name: str, rule_specs: list[dict]) -> None:
|
|
635
|
+
rules_dir = CODEX_ROOT / ".agents" / "rules"
|
|
636
|
+
rules_dir.mkdir(parents=True, exist_ok=True)
|
|
637
|
+
for spec in rule_specs:
|
|
638
|
+
dest = rules_dir / f"plugin-{name}-{spec['name']}.md"
|
|
639
|
+
shutil.copy2(spec["source"], dest)
|
|
640
|
+
print(f" Installed Codex rule: {dest.name}")
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _remove_codex_rules(name: str) -> None:
|
|
644
|
+
rules_dir = CODEX_ROOT / ".agents" / "rules"
|
|
645
|
+
if not rules_dir.is_dir():
|
|
646
|
+
return
|
|
647
|
+
for rule_file in sorted(rules_dir.glob(f"plugin-{name}-*.md")):
|
|
648
|
+
rule_file.unlink()
|
|
649
|
+
print(f" Removed Codex rule: {rule_file.name}")
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def install_pack_codex(name: str, pack: dict, pack_dir: Path) -> bool:
|
|
653
|
+
installed_items: list[str] = []
|
|
654
|
+
hook_specs = _resolve_pack_hooks(pack, pack_dir)
|
|
655
|
+
rule_specs = _resolve_pack_rules(pack, pack_dir)
|
|
656
|
+
|
|
657
|
+
_install_codex_base()
|
|
658
|
+
_install_codex_extra_skills(pack, pack_dir)
|
|
659
|
+
_copy_plugin_hook_scripts(name, hook_specs, installed_items)
|
|
660
|
+
_copy_plugin_scripts(name, pack_dir, installed_items)
|
|
661
|
+
_install_codex_rules(name, rule_specs)
|
|
662
|
+
_merge_codex_hooks(name, hook_specs)
|
|
663
|
+
|
|
664
|
+
print(f" Done: {name} for codex ({len(installed_items)} file items)")
|
|
665
|
+
return True
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def remove_pack_codex(name: str, pack: dict, pack_dir: Path, *, keep_shared_assets: bool) -> bool:
|
|
669
|
+
if not keep_shared_assets:
|
|
670
|
+
for hook in HOOKS_DIR.glob(f"plugin-{name}-*.sh"):
|
|
671
|
+
hook.unlink()
|
|
672
|
+
print(f" Removed hook: {hook.name}")
|
|
673
|
+
|
|
674
|
+
scripts_dir = TOOLKIT_DATA_DIR / "plugin-scripts" / name
|
|
675
|
+
if scripts_dir.is_dir():
|
|
676
|
+
shutil.rmtree(scripts_dir)
|
|
677
|
+
print(f" Removed scripts: {scripts_dir}")
|
|
678
|
+
|
|
679
|
+
_strip_codex_hooks(name)
|
|
680
|
+
_remove_codex_rules(name)
|
|
681
|
+
print(f" Done: removed {name} from codex")
|
|
682
|
+
return True
|
|
341
683
|
|
|
342
684
|
|
|
343
685
|
# ---------------------------------------------------------------------------
|
|
344
|
-
#
|
|
686
|
+
# Common actions
|
|
345
687
|
# ---------------------------------------------------------------------------
|
|
346
688
|
|
|
347
|
-
def
|
|
348
|
-
|
|
689
|
+
def install_pack(name: str, editor: str) -> bool:
|
|
690
|
+
pack = find_pack(name)
|
|
691
|
+
if not pack:
|
|
692
|
+
print(f" ERROR: plugin pack '{name}' not found")
|
|
693
|
+
print(f" Available: {', '.join(p['name'] for p in list_available())}")
|
|
694
|
+
return False
|
|
695
|
+
|
|
696
|
+
pack_dir = Path(pack["_dir"])
|
|
697
|
+
installed_items: list[str] = []
|
|
698
|
+
|
|
699
|
+
print(f" Installing: {name} for {editor} ({pack.get('description', '')})")
|
|
700
|
+
|
|
701
|
+
ok = install_pack_claude(name, pack, pack_dir) if editor == "claude" else install_pack_codex(name, pack, pack_dir)
|
|
702
|
+
if not ok:
|
|
703
|
+
return False
|
|
704
|
+
|
|
705
|
+
state = load_state()
|
|
706
|
+
installed = _installed_for(state, editor)
|
|
707
|
+
if name not in installed:
|
|
708
|
+
installed.append(name)
|
|
709
|
+
_set_installed(state, editor, installed)
|
|
710
|
+
save_state(state)
|
|
711
|
+
return True
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def remove_pack(name: str, editor: str) -> bool:
|
|
349
715
|
state = load_state()
|
|
350
|
-
|
|
351
|
-
|
|
716
|
+
installed = _installed_for(state, editor)
|
|
717
|
+
if name not in installed:
|
|
718
|
+
print(f" Plugin '{name}' is not installed for {editor}")
|
|
352
719
|
return False
|
|
353
720
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
721
|
+
pack = find_pack(name)
|
|
722
|
+
if not pack:
|
|
723
|
+
print(f" Plugin '{name}' manifest not found")
|
|
724
|
+
return False
|
|
725
|
+
|
|
726
|
+
pack_dir = Path(pack["_dir"])
|
|
727
|
+
print(f" Removing: {name} from {editor}")
|
|
728
|
+
keep_shared_assets = any(
|
|
729
|
+
name in _installed_for(state, other)
|
|
730
|
+
for other in VALID_EDITORS
|
|
731
|
+
if other != editor
|
|
732
|
+
)
|
|
733
|
+
ok = (
|
|
734
|
+
remove_pack_claude(name, pack, pack_dir, keep_shared_assets=keep_shared_assets)
|
|
735
|
+
if editor == "claude"
|
|
736
|
+
else remove_pack_codex(name, pack, pack_dir, keep_shared_assets=keep_shared_assets)
|
|
737
|
+
)
|
|
738
|
+
if not ok:
|
|
739
|
+
return False
|
|
740
|
+
|
|
741
|
+
installed = [p for p in installed if p != name]
|
|
742
|
+
_set_installed(state, editor, installed)
|
|
743
|
+
save_state(state)
|
|
744
|
+
return True
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def update_pack(name: str, editor: str) -> bool:
|
|
748
|
+
state = load_state()
|
|
749
|
+
if name not in _installed_for(state, editor):
|
|
750
|
+
print(f" Plugin '{name}' is not installed for {editor} — use 'install' instead")
|
|
751
|
+
return False
|
|
752
|
+
|
|
753
|
+
print(f" Updating: {name} for {editor}")
|
|
754
|
+
remove_pack(name, editor)
|
|
755
|
+
return install_pack(name, editor)
|
|
357
756
|
|
|
358
757
|
|
|
359
758
|
# ---------------------------------------------------------------------------
|
|
360
759
|
# Clean
|
|
361
760
|
# ---------------------------------------------------------------------------
|
|
362
761
|
|
|
363
|
-
MEMORY_DB = TOOLKIT_DATA_DIR / "memory.db"
|
|
364
|
-
|
|
365
|
-
# Map plugin names to their clean logic
|
|
366
762
|
CLEANABLE_PLUGINS = {"memory-pack"}
|
|
367
763
|
|
|
368
764
|
|
|
369
765
|
def clean_pack(name: str, days: int = 90) -> bool:
|
|
370
766
|
"""Prune old data for a plugin. Returns True if successful."""
|
|
371
767
|
state = load_state()
|
|
372
|
-
|
|
768
|
+
installed_anywhere = any(name in _installed_for(state, editor) for editor in VALID_EDITORS)
|
|
769
|
+
if not installed_anywhere:
|
|
373
770
|
print(f" Plugin '{name}' is not installed")
|
|
374
771
|
return False
|
|
375
772
|
|
|
@@ -393,17 +790,12 @@ def _clean_memory_pack(days: int) -> bool:
|
|
|
393
790
|
conn = sqlite.connect(str(MEMORY_DB))
|
|
394
791
|
cur = conn.cursor()
|
|
395
792
|
|
|
396
|
-
# Count before
|
|
397
793
|
before = cur.execute("SELECT COUNT(*) FROM observations").fetchone()[0]
|
|
398
|
-
|
|
399
|
-
# Delete old observations
|
|
400
794
|
cur.execute(
|
|
401
795
|
"DELETE FROM observations WHERE created_at < datetime('now', ?)",
|
|
402
796
|
(f"-{days} days",),
|
|
403
797
|
)
|
|
404
798
|
pruned_obs = cur.rowcount
|
|
405
|
-
|
|
406
|
-
# Delete orphan sessions
|
|
407
799
|
cur.execute(
|
|
408
800
|
"DELETE FROM sessions WHERE session_id NOT IN "
|
|
409
801
|
"(SELECT DISTINCT session_id FROM observations) "
|
|
@@ -439,62 +831,45 @@ def _human_size(size_bytes: int) -> str:
|
|
|
439
831
|
# List / Status
|
|
440
832
|
# ---------------------------------------------------------------------------
|
|
441
833
|
|
|
442
|
-
def cmd_list() -> None:
|
|
443
|
-
"""List available plugin packs."""
|
|
834
|
+
def cmd_list(editors: list[str]) -> None:
|
|
444
835
|
packs = list_available()
|
|
445
836
|
state = load_state()
|
|
446
837
|
|
|
447
838
|
print("Available plugin packs:")
|
|
448
839
|
print()
|
|
449
|
-
print(
|
|
450
|
-
|
|
840
|
+
print(
|
|
841
|
+
f" {'Name':<20} {'Domain':<12} {'Status':<14} {'Agents':>7} {'Skills':>7} {'Hooks':>6}"
|
|
842
|
+
f" {'Claude':>6} {'Codex':>6}"
|
|
843
|
+
)
|
|
844
|
+
print(
|
|
845
|
+
f" {'-'*20} {'-'*12} {'-'*14} {'-'*7} {'-'*7} {'-'*6}"
|
|
846
|
+
f" {'-'*6} {'-'*6}"
|
|
847
|
+
)
|
|
451
848
|
|
|
452
849
|
for pack in packs:
|
|
453
850
|
inc = pack.get("includes", {})
|
|
454
|
-
|
|
851
|
+
claude = "YES" if pack["name"] in _installed_for(state, "claude") else ""
|
|
852
|
+
codex = "YES" if pack["name"] in _installed_for(state, "codex") else ""
|
|
455
853
|
print(
|
|
456
854
|
f" {pack['name']:<20} {pack.get('domain',''):<12} {pack.get('status',''):<14}"
|
|
457
855
|
f" {len(inc.get('agents',[])):>7} {len(inc.get('skills',[])):>7} {len(inc.get('hooks',[])):>6}"
|
|
458
|
-
f" {
|
|
856
|
+
f" {claude:>6} {codex:>6}"
|
|
459
857
|
)
|
|
460
858
|
|
|
461
859
|
print()
|
|
462
|
-
print(
|
|
860
|
+
print(
|
|
861
|
+
f" Total: {len(packs)} packs | Claude: {len(_installed_for(state, 'claude'))}"
|
|
862
|
+
f" | Codex: {len(_installed_for(state, 'codex'))}"
|
|
863
|
+
)
|
|
463
864
|
print()
|
|
464
|
-
print(" Install:
|
|
465
|
-
print(" Install all: ai-toolkit plugin install --all")
|
|
466
|
-
print(" Update:
|
|
467
|
-
print("
|
|
468
|
-
print(" Clean:
|
|
469
|
-
print(" Remove: ai-toolkit plugin remove <name>")
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
def cmd_status() -> None:
|
|
473
|
-
"""Show installed plugins with details."""
|
|
474
|
-
state = load_state()
|
|
475
|
-
if not state["installed"]:
|
|
476
|
-
print("No plugins installed.")
|
|
477
|
-
print("Run: ai-toolkit plugin list")
|
|
478
|
-
return
|
|
479
|
-
|
|
480
|
-
print("Installed plugins:")
|
|
481
|
-
for name in state["installed"]:
|
|
482
|
-
pack = find_pack(name)
|
|
483
|
-
if pack:
|
|
484
|
-
print(f" {name}: {pack.get('description', '')}")
|
|
485
|
-
# Check hooks
|
|
486
|
-
hooks = list(HOOKS_DIR.glob(f"plugin-{name}-*.sh"))
|
|
487
|
-
if hooks:
|
|
488
|
-
print(f" Hooks: {', '.join(h.name for h in hooks)}")
|
|
489
|
-
# Show memory-pack DB stats
|
|
490
|
-
if name == "memory-pack":
|
|
491
|
-
_show_memory_stats()
|
|
492
|
-
else:
|
|
493
|
-
print(f" {name}: (manifest not found — orphaned?)")
|
|
865
|
+
print(" Install: ai-toolkit plugin install --editor claude|codex|all <name>")
|
|
866
|
+
print(" Install all: ai-toolkit plugin install --editor claude|codex|all --all")
|
|
867
|
+
print(" Update: ai-toolkit plugin update --editor claude|codex|all <name>")
|
|
868
|
+
print(" Remove: ai-toolkit plugin remove --editor claude|codex|all <name>")
|
|
869
|
+
print(" Clean: ai-toolkit plugin clean <name> [--days N]")
|
|
494
870
|
|
|
495
871
|
|
|
496
872
|
def _show_memory_stats() -> None:
|
|
497
|
-
"""Show memory-pack database statistics."""
|
|
498
873
|
if not MEMORY_DB.is_file():
|
|
499
874
|
print(" DB: not initialized")
|
|
500
875
|
return
|
|
@@ -514,71 +889,134 @@ def _show_memory_stats() -> None:
|
|
|
514
889
|
print(f" DB: {_human_size(MEMORY_DB.stat().st_size)} (error reading stats)")
|
|
515
890
|
|
|
516
891
|
|
|
892
|
+
def cmd_status(editors: list[str]) -> None:
|
|
893
|
+
state = load_state()
|
|
894
|
+
shown = False
|
|
895
|
+
for editor in editors:
|
|
896
|
+
installed = _installed_for(state, editor)
|
|
897
|
+
print(f"Installed plugins for {editor}:")
|
|
898
|
+
if not installed:
|
|
899
|
+
print(" (none)")
|
|
900
|
+
print()
|
|
901
|
+
continue
|
|
902
|
+
shown = True
|
|
903
|
+
for name in installed:
|
|
904
|
+
pack = find_pack(name)
|
|
905
|
+
if not pack:
|
|
906
|
+
print(f" {name}: (manifest not found — orphaned?)")
|
|
907
|
+
continue
|
|
908
|
+
print(f" {name}: {pack.get('description', '')}")
|
|
909
|
+
if editor == "claude":
|
|
910
|
+
hooks = list(HOOKS_DIR.glob(f"plugin-{name}-*.sh"))
|
|
911
|
+
if hooks:
|
|
912
|
+
print(f" Hooks: {', '.join(h.name for h in hooks)}")
|
|
913
|
+
elif editor == "codex":
|
|
914
|
+
rules_dir = CODEX_ROOT / ".agents" / "rules"
|
|
915
|
+
rule_files = sorted(rules_dir.glob(f"ai-toolkit-plugin-{name}-*.md")) if rules_dir.is_dir() else []
|
|
916
|
+
if rule_files:
|
|
917
|
+
print(f" Rules: {', '.join(f.name for f in rule_files)}")
|
|
918
|
+
if name == "memory-pack":
|
|
919
|
+
_show_memory_stats()
|
|
920
|
+
print()
|
|
921
|
+
|
|
922
|
+
if not shown and all(not _installed_for(state, editor) for editor in editors):
|
|
923
|
+
print("Run: ai-toolkit plugin list")
|
|
924
|
+
|
|
925
|
+
|
|
517
926
|
# ---------------------------------------------------------------------------
|
|
518
|
-
#
|
|
927
|
+
# CLI parsing
|
|
519
928
|
# ---------------------------------------------------------------------------
|
|
520
929
|
|
|
521
|
-
def
|
|
930
|
+
def _parse_editors(args: list[str]) -> tuple[list[str], list[str]]:
|
|
931
|
+
editors = ["claude"]
|
|
932
|
+
remainder: list[str] = []
|
|
933
|
+
i = 0
|
|
934
|
+
while i < len(args):
|
|
935
|
+
arg = args[i]
|
|
936
|
+
if arg.startswith("--editor="):
|
|
937
|
+
value = arg.split("=", 1)[1]
|
|
938
|
+
elif arg == "--editor":
|
|
939
|
+
if i + 1 >= len(args):
|
|
940
|
+
print("ERROR: --editor requires a value")
|
|
941
|
+
sys.exit(1)
|
|
942
|
+
i += 1
|
|
943
|
+
value = args[i]
|
|
944
|
+
else:
|
|
945
|
+
remainder.append(arg)
|
|
946
|
+
i += 1
|
|
947
|
+
continue
|
|
948
|
+
|
|
949
|
+
if value == "all":
|
|
950
|
+
editors = list(VALID_EDITORS)
|
|
951
|
+
else:
|
|
952
|
+
parsed = [item.strip() for item in value.split(",") if item.strip()]
|
|
953
|
+
invalid = [item for item in parsed if item not in VALID_EDITORS]
|
|
954
|
+
if invalid:
|
|
955
|
+
print(f"ERROR: unsupported editor(s): {', '.join(invalid)}")
|
|
956
|
+
print("Valid values: claude, codex, all")
|
|
957
|
+
sys.exit(1)
|
|
958
|
+
editors = parsed or ["claude"]
|
|
959
|
+
i += 1
|
|
960
|
+
return editors, remainder
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def _cmd_install(args: list[str], editors: list[str]) -> None:
|
|
522
964
|
if not args:
|
|
523
|
-
print("Usage: ai-toolkit plugin install <pack-name> [...]")
|
|
524
|
-
print(" ai-toolkit plugin install --all")
|
|
965
|
+
print("Usage: ai-toolkit plugin install [--editor claude|codex|all] <pack-name> [...]")
|
|
966
|
+
print(" ai-toolkit plugin install [--editor claude|codex|all] --all")
|
|
525
967
|
sys.exit(1)
|
|
526
|
-
if "--all" in args
|
|
527
|
-
|
|
528
|
-
|
|
968
|
+
names = [pack["name"] for pack in list_available()] if "--all" in args else args
|
|
969
|
+
for editor in editors:
|
|
970
|
+
if "--all" in args:
|
|
971
|
+
print(f"Installing all {len(names)} plugin packs for {editor}...\n")
|
|
529
972
|
ok = 0
|
|
530
|
-
for
|
|
531
|
-
if install_pack(
|
|
973
|
+
for name in names:
|
|
974
|
+
if install_pack(name, editor):
|
|
532
975
|
ok += 1
|
|
533
976
|
print()
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
for name in args:
|
|
537
|
-
install_pack(name)
|
|
977
|
+
if "--all" in args:
|
|
978
|
+
print(f"Installed: {ok}/{len(names)} packs for {editor}")
|
|
538
979
|
print()
|
|
539
980
|
|
|
540
981
|
|
|
541
|
-
def _cmd_remove(args: list[str]) -> None:
|
|
982
|
+
def _cmd_remove(args: list[str], editors: list[str]) -> None:
|
|
542
983
|
if not args:
|
|
543
|
-
print("Usage: ai-toolkit plugin remove <pack-name> [...]")
|
|
544
|
-
print(" ai-toolkit plugin remove --all")
|
|
984
|
+
print("Usage: ai-toolkit plugin remove [--editor claude|codex|all] <pack-name> [...]")
|
|
985
|
+
print(" ai-toolkit plugin remove [--editor claude|codex|all] --all")
|
|
545
986
|
sys.exit(1)
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
names = list(state
|
|
987
|
+
state = load_state()
|
|
988
|
+
for editor in editors:
|
|
989
|
+
names = list(_installed_for(state, editor)) if "--all" in args else args
|
|
549
990
|
if not names:
|
|
550
|
-
print("No plugins installed.")
|
|
551
|
-
return
|
|
552
|
-
for name in names:
|
|
553
|
-
remove_pack(name)
|
|
991
|
+
print(f"No plugins installed for {editor}.")
|
|
554
992
|
print()
|
|
555
|
-
|
|
556
|
-
for name in
|
|
557
|
-
remove_pack(name)
|
|
993
|
+
continue
|
|
994
|
+
for name in names:
|
|
995
|
+
remove_pack(name, editor)
|
|
558
996
|
print()
|
|
559
997
|
|
|
560
998
|
|
|
561
|
-
def _cmd_update(args: list[str]) -> None:
|
|
999
|
+
def _cmd_update(args: list[str], editors: list[str]) -> None:
|
|
562
1000
|
if not args:
|
|
563
|
-
print("Usage: ai-toolkit plugin update <pack-name> [...]")
|
|
564
|
-
print(" ai-toolkit plugin update --all")
|
|
1001
|
+
print("Usage: ai-toolkit plugin update [--editor claude|codex|all] <pack-name> [...]")
|
|
1002
|
+
print(" ai-toolkit plugin update [--editor claude|codex|all] --all")
|
|
565
1003
|
sys.exit(1)
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
names = list(state
|
|
1004
|
+
state = load_state()
|
|
1005
|
+
for editor in editors:
|
|
1006
|
+
names = list(_installed_for(state, editor)) if "--all" in args else args
|
|
569
1007
|
if not names:
|
|
570
|
-
print("No plugins installed.")
|
|
571
|
-
|
|
572
|
-
|
|
1008
|
+
print(f"No plugins installed for {editor}.")
|
|
1009
|
+
print()
|
|
1010
|
+
continue
|
|
1011
|
+
if "--all" in args:
|
|
1012
|
+
print(f"Updating {len(names)} installed plugin(s) for {editor}...\n")
|
|
573
1013
|
ok = 0
|
|
574
1014
|
for name in names:
|
|
575
|
-
if update_pack(name):
|
|
1015
|
+
if update_pack(name, editor):
|
|
576
1016
|
ok += 1
|
|
577
1017
|
print()
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
for name in args:
|
|
581
|
-
update_pack(name)
|
|
1018
|
+
if "--all" in args:
|
|
1019
|
+
print(f"Updated: {ok}/{len(names)} packs for {editor}")
|
|
582
1020
|
print()
|
|
583
1021
|
|
|
584
1022
|
|
|
@@ -591,10 +1029,10 @@ def _parse_clean_args(args: list[str]) -> tuple[list[str], int]:
|
|
|
591
1029
|
try:
|
|
592
1030
|
days = int(args[i + 1])
|
|
593
1031
|
if days <= 0:
|
|
594
|
-
print(f"
|
|
1032
|
+
print(f"ERROR: --days must be positive, got {days}")
|
|
595
1033
|
sys.exit(1)
|
|
596
1034
|
except ValueError:
|
|
597
|
-
print(f"
|
|
1035
|
+
print(f"ERROR: --days requires a number, got '{args[i + 1]}'")
|
|
598
1036
|
sys.exit(1)
|
|
599
1037
|
i += 2
|
|
600
1038
|
else:
|
|
@@ -623,24 +1061,30 @@ def main() -> None:
|
|
|
623
1061
|
sys.exit(1)
|
|
624
1062
|
|
|
625
1063
|
action = sys.argv[1]
|
|
626
|
-
args = sys.argv[2:]
|
|
627
|
-
|
|
628
|
-
dispatch = {
|
|
629
|
-
"list": lambda: cmd_list(),
|
|
630
|
-
"status": lambda: cmd_status(),
|
|
631
|
-
"install": lambda: _cmd_install(args),
|
|
632
|
-
"remove": lambda: _cmd_remove(args),
|
|
633
|
-
"update": lambda: _cmd_update(args),
|
|
634
|
-
"clean": lambda: _cmd_clean(args),
|
|
635
|
-
}
|
|
1064
|
+
editors, args = _parse_editors(sys.argv[2:])
|
|
636
1065
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
1066
|
+
if action == "list":
|
|
1067
|
+
cmd_list(editors)
|
|
1068
|
+
return
|
|
1069
|
+
if action == "status":
|
|
1070
|
+
cmd_status(editors)
|
|
1071
|
+
return
|
|
1072
|
+
if action == "install":
|
|
1073
|
+
_cmd_install(args, editors)
|
|
1074
|
+
return
|
|
1075
|
+
if action == "remove":
|
|
1076
|
+
_cmd_remove(args, editors)
|
|
1077
|
+
return
|
|
1078
|
+
if action == "update":
|
|
1079
|
+
_cmd_update(args, editors)
|
|
1080
|
+
return
|
|
1081
|
+
if action == "clean":
|
|
1082
|
+
_cmd_clean(args)
|
|
1083
|
+
return
|
|
1084
|
+
|
|
1085
|
+
print(f"Unknown action: {action}")
|
|
1086
|
+
print("Actions: install, remove, update, clean, list, status")
|
|
1087
|
+
sys.exit(1)
|
|
644
1088
|
|
|
645
1089
|
|
|
646
1090
|
if __name__ == "__main__":
|