@softspark/ai-toolkit 2.0.2 → 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/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 install <pack-name> [<pack-name> ...]
6
- plugin.py install --all
7
- plugin.py remove <pack-name> [<pack-name> ...]
8
- plugin.py remove --all
9
- plugin.py update <pack-name> [<pack-name> ...]
10
- plugin.py update --all
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 Copy plugin hooks/scripts, verify agents+skills are linked
17
- remove Remove plugin hooks/scripts, leave core agents+skills intact
18
- update Re-install plugin (remove + install), --all updates all installed
19
- clean Prune old data (e.g. memory-pack observations older than --days N, default 90)
20
- list Show available plugin packs with status
21
- status Show what's currently installed
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 toolkit_dir, app_dir
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
- return json.load(f)
78
+ with open(PLUGINS_STATE_FILE, encoding="utf-8") as f:
79
+ raw = json.load(f)
56
80
  except (json.JSONDecodeError, OSError):
57
- pass
58
- return {"installed": []}
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
- # Install
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
- def _link_skills(includes: dict, pack_dir: Path, installed_items: list[str]) -> None:
120
- """Verify and link referenced skills from a plugin pack."""
121
- for skill in includes.get("skills", []):
122
- skill_dir = CLAUDE_DIR / "skills" / skill
123
- source_dir = app_dir / "skills" / skill
124
- if skill_dir.exists() or skill_dir.is_symlink():
125
- print(f" OK skill: {skill}")
126
- elif source_dir.is_dir():
127
- skill_dir.parent.mkdir(parents=True, exist_ok=True)
128
- skill_dir.symlink_to(source_dir)
129
- print(f" Linked skill: {skill}")
130
- installed_items.append(f"skill:{skill}")
131
- else:
132
- plugin_skill = pack_dir / "skills" / skill / "SKILL.md"
133
- if plugin_skill.is_file():
134
- skill_dir.parent.mkdir(parents=True, exist_ok=True)
135
- skill_dir.symlink_to(pack_dir / "skills" / skill)
136
- print(f" Linked skill (from plugin): {skill}")
137
- installed_items.append(f"skill:{skill}")
138
- else:
139
- print(f" WARN skill not found: {skill}")
140
-
141
-
142
- def _copy_hooks(name: str, pack_dir: Path, installed_items: list[str]) -> None:
143
- """Copy plugin-specific hook scripts."""
144
- plugin_hooks_dir = pack_dir / "hooks"
145
- if not plugin_hooks_dir.is_dir():
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(plugin_hooks_dir.glob("*.sh")):
149
- dest = HOOKS_DIR / f"plugin-{name}-{hook_file.name}"
150
- shutil.copy2(hook_file, dest)
151
- dest.chmod(dest.stat().st_mode | 0o111)
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 _copy_scripts(name: str, pack_dir: Path, installed_items: list[str]) -> None:
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, text=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
- else:
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 install_pack(name: str) -> bool:
186
- """Install a single plugin pack. Returns True if successful."""
187
- pack = find_pack(name)
188
- if not pack:
189
- print(f" ERROR: plugin pack '{name}' not found")
190
- print(f" Available: {', '.join(p['name'] for p in list_available())}")
191
- return False
192
-
193
- pack_dir = Path(pack["_dir"])
194
- includes = pack.get("includes", {})
195
- installed_items: list[str] = []
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
- _link_agents(includes, pack_dir, installed_items)
200
- _link_skills(includes, pack_dir, installed_items)
201
- _copy_hooks(name, pack_dir, installed_items)
202
- _copy_scripts(name, pack_dir, installed_items)
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
- state = load_state()
209
- if name not in state["installed"]:
210
- state["installed"].append(name)
211
- save_state(state)
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 _inject_plugin_hooks(name: str, pack_dir: Path) -> None:
218
- """Register plugin hooks in settings.json via manual JSON merge."""
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
- return
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
- plugin_hooks_dir = pack_dir / "hooks"
400
+ source_tag = f"ai-toolkit-plugin-{name}"
231
401
 
232
- for hook_file in sorted(plugin_hooks_dir.glob("*.sh")):
233
- # Determine event from hook filename or manifest
234
- event = _guess_event(hook_file.name, name)
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": f"ai-toolkit-plugin-{name}",
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": str(dest_path),
412
+ "command": _plugin_hook_command(name, spec),
246
413
  }
247
414
  ],
248
415
  }
249
-
250
- event_hooks = hooks.setdefault(event, [])
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
- with open(settings_path, "w") as f:
257
- json.dump(settings, f, indent=4)
258
- print(f" Merged hooks into settings.json")
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
- Uses hook_events from the plugin manifest if available,
265
- falls back to filename-based guessing.
266
- """
267
- pack = find_pack(pack_name)
268
- if pack:
269
- return resolve_hook_event(hook_filename, pack)
270
- # Fallback for unknown packs
271
- return resolve_hook_event(hook_filename, {})
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
- # Remove
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
- print(f" Removing: {name}")
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
- # 2. Remove plugin scripts
295
- scripts_dir = TOOLKIT_DATA_DIR / "plugin-scripts" / name
296
- if scripts_dir.is_dir():
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
- # 3. Strip plugin hooks from settings.json
302
- _strip_plugin_hooks(name)
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
- # 4. Update state
305
- state["installed"] = [p for p in state["installed"] if p != name]
306
- save_state(state)
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
- print(f" Done: removed {name}")
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
- def _strip_plugin_hooks(name: str) -> None:
313
- """Remove plugin hooks from settings.json."""
314
- settings_path = CLAUDE_DIR / "settings.json"
315
- if not settings_path.is_file():
316
- return
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(hooks.keys()):
329
- original = hooks[event]
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
- hooks[event] = filtered
624
+ bucket[event] = filtered
333
625
  changed = True
334
- if not hooks[event]:
335
- del hooks[event]
626
+ if not bucket[event]:
627
+ del bucket[event]
336
628
 
337
629
  if changed:
338
- with open(settings_path, "w") as f:
339
- json.dump(settings, f, indent=4)
340
- print(f" Stripped hooks from settings.json")
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
- # Update
686
+ # Common actions
345
687
  # ---------------------------------------------------------------------------
346
688
 
347
- def update_pack(name: str) -> bool:
348
- """Update a single plugin pack (remove + install). Returns True if successful."""
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
- if name not in state["installed"]:
351
- print(f" Plugin '{name}' is not installed — use 'install' instead")
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
- print(f" Updating: {name}")
355
- remove_pack(name)
356
- return install_pack(name)
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
- if name not in state["installed"]:
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(f" {'Name':<20} {'Domain':<12} {'Status':<14} {'Agents':>7} {'Skills':>7} {'Hooks':>6} Installed")
450
- print(f" {'-'*20} {'-'*12} {'-'*14} {'-'*7} {'-'*7} {'-'*6} {'-'*9}")
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
- installed = "YES" if pack["name"] in state["installed"] else ""
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" {installed}"
856
+ f" {claude:>6} {codex:>6}"
459
857
  )
460
858
 
461
859
  print()
462
- print(f" Total: {len(packs)} packs, {len(state['installed'])} installed")
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: ai-toolkit plugin install <name>")
465
- print(" Install all: ai-toolkit plugin install --all")
466
- print(" Update: ai-toolkit plugin update <name>")
467
- print(" Update all: ai-toolkit plugin update --all")
468
- print(" Clean: ai-toolkit plugin clean <name> [--days N]")
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
- # Main
927
+ # CLI parsing
519
928
  # ---------------------------------------------------------------------------
520
929
 
521
- def _cmd_install(args: list[str]) -> None:
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
- packs = list_available()
528
- print(f"Installing all {len(packs)} plugin packs...\n")
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 pack in packs:
531
- if install_pack(pack["name"]):
973
+ for name in names:
974
+ if install_pack(name, editor):
532
975
  ok += 1
533
976
  print()
534
- print(f"Installed: {ok}/{len(packs)} packs")
535
- else:
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
- if "--all" in args:
547
- state = load_state()
548
- names = list(state["installed"])
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
- else:
556
- for name in args:
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
- if "--all" in args:
567
- state = load_state()
568
- names = list(state["installed"])
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
- return
572
- print(f"Updating {len(names)} installed plugin(s)...\n")
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
- print(f"Updated: {ok}/{len(names)} packs")
579
- else:
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" ERROR: --days must be positive, got {days}")
1032
+ print(f"ERROR: --days must be positive, got {days}")
595
1033
  sys.exit(1)
596
1034
  except ValueError:
597
- print(f" ERROR: --days requires a number, got '{args[i + 1]}'")
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
- handler = dispatch.get(action)
638
- if handler:
639
- handler()
640
- else:
641
- print(f"Unknown action: {action}")
642
- print("Actions: install, remove, update, clean, list, status")
643
- sys.exit(1)
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__":