@softspark/ai-toolkit 2.3.1 → 2.4.1
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 +33 -0
- package/README.md +8 -8
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/ARCHITECTURE.md +4 -4
- package/app/rules/common/coding-style.md +16 -2
- package/bin/ai-toolkit.js +44 -13
- package/kb/reference/architecture-overview.md +4 -4
- package/kb/reference/cli-reference.md +4 -4
- package/kb/reference/codex-cli-compatibility.md +4 -0
- package/kb/reference/extension-api.md +24 -13
- package/llms-full.txt +33 -18
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/config_resolver.py +23 -5
- package/scripts/doctor.py +73 -1
- package/scripts/hook_sources.py +109 -0
- package/scripts/inject_hook_cli.py +230 -25
- package/scripts/install.py +38 -4
- package/scripts/install_steps/ai_tools.py +78 -15
- package/scripts/install_steps/install_state.py +25 -0
- package/scripts/install_steps/markers.py +42 -0
- package/scripts/install_steps/project_registry.py +9 -0
- package/scripts/paths.py +1 -0
- package/scripts/propagate_global.py +92 -0
- package/scripts/rule_sources.py +5 -28
- package/scripts/update_projects.py +7 -1
- package/scripts/url_fetch.py +50 -0
package/scripts/doctor.py
CHANGED
|
@@ -10,6 +10,7 @@ Checks:
|
|
|
10
10
|
6. Planned assets
|
|
11
11
|
7. Benchmark freshness
|
|
12
12
|
8. Stale rules
|
|
13
|
+
9. URL hook sources
|
|
13
14
|
|
|
14
15
|
Exit codes:
|
|
15
16
|
0 all checks pass
|
|
@@ -27,7 +28,7 @@ from pathlib import Path
|
|
|
27
28
|
|
|
28
29
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
29
30
|
from _common import toolkit_dir
|
|
30
|
-
from paths import HOOKS_DIR as _HOOKS_DIR, RULES_DIR as _RULES_DIR
|
|
31
|
+
from paths import HOOKS_DIR as _HOOKS_DIR, RULES_DIR as _RULES_DIR, EXTERNAL_HOOKS_DIR as _EXTERNAL_HOOKS_DIR
|
|
31
32
|
|
|
32
33
|
|
|
33
34
|
# ---------------------------------------------------------------------------
|
|
@@ -37,6 +38,7 @@ from paths import HOOKS_DIR as _HOOKS_DIR, RULES_DIR as _RULES_DIR
|
|
|
37
38
|
CLAUDE_DIR = Path.home() / ".claude"
|
|
38
39
|
HOOKS_DIR = _HOOKS_DIR
|
|
39
40
|
RULES_DIR = _RULES_DIR
|
|
41
|
+
EXTERNAL_HOOKS_DIR = _EXTERNAL_HOOKS_DIR
|
|
40
42
|
BENCHMARK_DASHBOARD = toolkit_dir / "benchmarks" / "ecosystem-dashboard.json"
|
|
41
43
|
|
|
42
44
|
VALID_EVENTS = frozenset({
|
|
@@ -465,6 +467,75 @@ def check_stale_rules(dr: DiagResult, fix_mode: bool) -> None:
|
|
|
465
467
|
dr.ok("All rules healthy")
|
|
466
468
|
|
|
467
469
|
|
|
470
|
+
# ---------------------------------------------------------------------------
|
|
471
|
+
# Check 9: URL Hook Sources
|
|
472
|
+
# ---------------------------------------------------------------------------
|
|
473
|
+
|
|
474
|
+
def check_url_hooks(dr: DiagResult, fix_mode: bool) -> None:
|
|
475
|
+
"""Check URL-sourced hook cache integrity."""
|
|
476
|
+
print()
|
|
477
|
+
print("## 9. URL Hook Sources")
|
|
478
|
+
|
|
479
|
+
sources_file = EXTERNAL_HOOKS_DIR / "sources.json"
|
|
480
|
+
if not sources_file.is_file():
|
|
481
|
+
dr.skip("No URL hook sources registered")
|
|
482
|
+
return
|
|
483
|
+
|
|
484
|
+
try:
|
|
485
|
+
with open(sources_file, encoding="utf-8") as f:
|
|
486
|
+
data = json.load(f)
|
|
487
|
+
sources = data.get("hooks", {})
|
|
488
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
489
|
+
dr.fail(f"Corrupt sources.json: {exc}")
|
|
490
|
+
return
|
|
491
|
+
|
|
492
|
+
if not sources:
|
|
493
|
+
dr.ok("No URL hook sources registered")
|
|
494
|
+
return
|
|
495
|
+
|
|
496
|
+
issues = 0
|
|
497
|
+
for name, entry in sources.items():
|
|
498
|
+
cached = EXTERNAL_HOOKS_DIR / f"{name}.json"
|
|
499
|
+
url = entry.get("url", "")
|
|
500
|
+
|
|
501
|
+
if not cached.is_file():
|
|
502
|
+
dr.warn(f"Missing cached file for '{name}' ({url})")
|
|
503
|
+
issues += 1
|
|
504
|
+
if fix_mode:
|
|
505
|
+
try:
|
|
506
|
+
from url_fetch import fetch_url
|
|
507
|
+
content = fetch_url(url)
|
|
508
|
+
json.loads(content) # validate
|
|
509
|
+
EXTERNAL_HOOKS_DIR.mkdir(parents=True, exist_ok=True)
|
|
510
|
+
cached.write_bytes(content)
|
|
511
|
+
print(f" FIXED: re-fetched {name}")
|
|
512
|
+
except Exception as exc:
|
|
513
|
+
print(f" Could not re-fetch: {exc}")
|
|
514
|
+
continue
|
|
515
|
+
|
|
516
|
+
# Validate cached file is valid JSON with hooks key
|
|
517
|
+
try:
|
|
518
|
+
with open(cached, encoding="utf-8") as f:
|
|
519
|
+
hook_data = json.load(f)
|
|
520
|
+
if "hooks" not in hook_data:
|
|
521
|
+
dr.warn(f"Cached file '{name}' missing 'hooks' key")
|
|
522
|
+
issues += 1
|
|
523
|
+
else:
|
|
524
|
+
dr.ok(f"{name} ({url})")
|
|
525
|
+
except json.JSONDecodeError:
|
|
526
|
+
dr.warn(f"Corrupt cached file: {cached}")
|
|
527
|
+
issues += 1
|
|
528
|
+
if fix_mode:
|
|
529
|
+
try:
|
|
530
|
+
from url_fetch import fetch_url
|
|
531
|
+
content = fetch_url(url)
|
|
532
|
+
json.loads(content)
|
|
533
|
+
cached.write_bytes(content)
|
|
534
|
+
print(f" FIXED: re-fetched {name}")
|
|
535
|
+
except Exception as exc:
|
|
536
|
+
print(f" Could not re-fetch: {exc}")
|
|
537
|
+
|
|
538
|
+
|
|
468
539
|
# ---------------------------------------------------------------------------
|
|
469
540
|
# Main
|
|
470
541
|
# ---------------------------------------------------------------------------
|
|
@@ -486,6 +557,7 @@ def main() -> None:
|
|
|
486
557
|
check_planned_assets(dr)
|
|
487
558
|
check_benchmark_freshness(dr)
|
|
488
559
|
check_stale_rules(dr, fix_mode)
|
|
560
|
+
check_url_hooks(dr, fix_mode)
|
|
489
561
|
|
|
490
562
|
# Summary
|
|
491
563
|
print("========================")
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""URL source registry for remotely-sourced hooks.
|
|
3
|
+
|
|
4
|
+
Tracks which hooks were registered from a URL so that `ai-toolkit update`
|
|
5
|
+
can re-fetch the latest version before injection.
|
|
6
|
+
|
|
7
|
+
Metadata stored in ~/.softspark/ai-toolkit/hooks/external/sources.json.
|
|
8
|
+
|
|
9
|
+
Stdlib-only — no external dependencies.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
22
|
+
from paths import EXTERNAL_HOOKS_DIR
|
|
23
|
+
|
|
24
|
+
_SOURCES_FILENAME = "sources.json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Load / Save
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
def _sources_path(hooks_dir: Path | None = None) -> Path:
|
|
32
|
+
return (hooks_dir or EXTERNAL_HOOKS_DIR) / _SOURCES_FILENAME
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_sources(hooks_dir: Path | None = None) -> dict[str, dict[str, Any]]:
|
|
36
|
+
"""Load sources.json. Returns {} if missing or corrupt."""
|
|
37
|
+
path = _sources_path(hooks_dir)
|
|
38
|
+
if not path.is_file():
|
|
39
|
+
return {}
|
|
40
|
+
try:
|
|
41
|
+
with open(path, encoding="utf-8") as f:
|
|
42
|
+
data = json.load(f)
|
|
43
|
+
if isinstance(data, dict):
|
|
44
|
+
return data.get("hooks", {})
|
|
45
|
+
return {}
|
|
46
|
+
except (json.JSONDecodeError, OSError):
|
|
47
|
+
return {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def save_sources(hooks_dir: Path | None = None,
|
|
51
|
+
sources: dict[str, dict[str, Any]] | None = None) -> None:
|
|
52
|
+
"""Write sources.json atomically."""
|
|
53
|
+
hooks_dir = hooks_dir or EXTERNAL_HOOKS_DIR
|
|
54
|
+
path = _sources_path(hooks_dir)
|
|
55
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
|
|
57
|
+
payload = json.dumps({"schema_version": 1, "hooks": sources or {}}, indent=2)
|
|
58
|
+
|
|
59
|
+
fd, tmp_path = tempfile.mkstemp(
|
|
60
|
+
dir=str(path.parent), prefix=".sources_", suffix=".tmp"
|
|
61
|
+
)
|
|
62
|
+
try:
|
|
63
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
64
|
+
f.write(payload)
|
|
65
|
+
f.write("\n")
|
|
66
|
+
f.flush()
|
|
67
|
+
os.fsync(f.fileno())
|
|
68
|
+
os.rename(tmp_path, str(path))
|
|
69
|
+
except BaseException:
|
|
70
|
+
try:
|
|
71
|
+
os.unlink(tmp_path)
|
|
72
|
+
except OSError:
|
|
73
|
+
pass
|
|
74
|
+
raise
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# CRUD
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def register_url_source(hooks_dir: Path | None, hook_name: str, url: str) -> None:
|
|
82
|
+
"""Add or update a URL source entry."""
|
|
83
|
+
import re
|
|
84
|
+
if not hook_name or not re.fullmatch(r"[a-zA-Z0-9_-]+", hook_name):
|
|
85
|
+
raise ValueError(f"Invalid hook name: {hook_name!r}")
|
|
86
|
+
hooks_dir = hooks_dir or EXTERNAL_HOOKS_DIR
|
|
87
|
+
sources = load_sources(hooks_dir)
|
|
88
|
+
sources[hook_name] = {
|
|
89
|
+
"url": url,
|
|
90
|
+
"fetched_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
91
|
+
}
|
|
92
|
+
save_sources(hooks_dir, sources)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def unregister_source(hooks_dir: Path | None, hook_name: str) -> bool:
|
|
96
|
+
"""Remove a source entry. Returns True if found and removed."""
|
|
97
|
+
hooks_dir = hooks_dir or EXTERNAL_HOOKS_DIR
|
|
98
|
+
sources = load_sources(hooks_dir)
|
|
99
|
+
if hook_name in sources:
|
|
100
|
+
del sources[hook_name]
|
|
101
|
+
save_sources(hooks_dir, sources)
|
|
102
|
+
return True
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def get_url_hooks(hooks_dir: Path | None = None) -> dict[str, str]:
|
|
107
|
+
"""Return {hook_name: url} for all URL-sourced hooks."""
|
|
108
|
+
sources = load_sources(hooks_dir)
|
|
109
|
+
return {name: entry["url"] for name, entry in sources.items() if "url" in entry}
|
|
@@ -7,15 +7,20 @@ hooks alongside ai-toolkit's hooks. Each injected file is tagged with a
|
|
|
7
7
|
and removal is safe.
|
|
8
8
|
|
|
9
9
|
Usage:
|
|
10
|
-
inject_hook_cli.py <hooks-file
|
|
10
|
+
inject_hook_cli.py <hooks-file-or-url> [hook-name] [target-dir]
|
|
11
11
|
inject_hook_cli.py --remove <hook-source-name> [target-dir]
|
|
12
12
|
|
|
13
13
|
Arguments:
|
|
14
|
-
hooks-file
|
|
15
|
-
|
|
14
|
+
hooks-file-or-url Path to a JSON file or HTTPS URL with
|
|
15
|
+
``{"hooks": {"EventName": [...]}}``
|
|
16
|
+
hook-name Override the source name (default: filename stem or
|
|
17
|
+
URL last segment)
|
|
18
|
+
target-dir Directory containing ``.claude/settings.json``
|
|
19
|
+
(default: $HOME)
|
|
16
20
|
|
|
17
21
|
Flags:
|
|
18
22
|
--remove Remove all hook entries tagged with the given source name
|
|
23
|
+
(also unregisters URL source if present)
|
|
19
24
|
|
|
20
25
|
The source name is derived from the filename stem (e.g.,
|
|
21
26
|
``rag-mcp-hooks.json`` becomes ``"rag-mcp-hooks"``). All entries are tagged
|
|
@@ -34,12 +39,19 @@ from __future__ import annotations
|
|
|
34
39
|
|
|
35
40
|
import json
|
|
36
41
|
import os
|
|
42
|
+
import re
|
|
37
43
|
import sys
|
|
44
|
+
import urllib.parse
|
|
38
45
|
from pathlib import Path
|
|
39
46
|
|
|
47
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
48
|
+
|
|
40
49
|
# Protected source tag -- this CLI must never touch ai-toolkit's own entries.
|
|
41
50
|
PROTECTED_SOURCE = "ai-toolkit"
|
|
42
51
|
|
|
52
|
+
# Codex CLI supports only these 5 hook events.
|
|
53
|
+
CODEX_EVENTS = {"SessionStart", "PreToolUse", "PostToolUse", "UserPromptSubmit", "Stop"}
|
|
54
|
+
|
|
43
55
|
|
|
44
56
|
# ---------------------------------------------------------------------------
|
|
45
57
|
# JSON helpers (same style as merge-hooks.py)
|
|
@@ -70,6 +82,23 @@ def save_json(path: str, data: dict) -> None:
|
|
|
70
82
|
f.write("\n")
|
|
71
83
|
|
|
72
84
|
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# URL helpers
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def _is_url(source: str) -> bool:
|
|
90
|
+
"""Check if source looks like an HTTP(S) URL."""
|
|
91
|
+
return source.startswith("https://") or source.startswith("http://")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _name_from_url(url: str) -> str:
|
|
95
|
+
"""Derive a hook source name from a URL's last path segment."""
|
|
96
|
+
parsed = urllib.parse.urlparse(url)
|
|
97
|
+
filename = parsed.path.rstrip("/").split("/")[-1]
|
|
98
|
+
stem = filename.rsplit(".", 1)[0] if "." in filename else filename
|
|
99
|
+
return re.sub(r"[^a-zA-Z0-9_-]", "", stem)
|
|
100
|
+
|
|
101
|
+
|
|
73
102
|
# ---------------------------------------------------------------------------
|
|
74
103
|
# Core logic
|
|
75
104
|
# ---------------------------------------------------------------------------
|
|
@@ -153,27 +182,161 @@ def merge_hooks(new_hooks: dict, existing_hooks: dict, source: str) -> dict:
|
|
|
153
182
|
return merged
|
|
154
183
|
|
|
155
184
|
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
# Codex propagation
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
def _codex_hooks_path(target_dir: str) -> Path:
|
|
190
|
+
"""Return the global Codex hooks.json path."""
|
|
191
|
+
return Path(target_dir) / ".codex" / "hooks.json"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _filter_codex_events(hooks: dict) -> dict:
|
|
195
|
+
"""Keep only events supported by Codex CLI."""
|
|
196
|
+
return {event: entries for event, entries in hooks.items()
|
|
197
|
+
if event in CODEX_EVENTS}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _inject_codex(tagged_hooks: dict, source: str, target_dir: str) -> None:
|
|
201
|
+
"""Propagate hook entries to ~/.codex/hooks.json (Codex global layer).
|
|
202
|
+
|
|
203
|
+
Only events in CODEX_EVENTS are propagated. Non-Codex events are silently
|
|
204
|
+
skipped.
|
|
205
|
+
"""
|
|
206
|
+
codex_hooks = _filter_codex_events(tagged_hooks)
|
|
207
|
+
if not codex_hooks:
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
codex_path = _codex_hooks_path(target_dir)
|
|
211
|
+
codex_path.parent.mkdir(parents=True, exist_ok=True)
|
|
212
|
+
|
|
213
|
+
existing: dict = {}
|
|
214
|
+
if codex_path.is_file():
|
|
215
|
+
try:
|
|
216
|
+
data = load_json(str(codex_path))
|
|
217
|
+
existing = data.get("hooks", {})
|
|
218
|
+
except (json.JSONDecodeError, OSError):
|
|
219
|
+
existing = {}
|
|
220
|
+
|
|
221
|
+
merged = merge_hooks(codex_hooks, existing, source)
|
|
222
|
+
save_json(str(codex_path), {"hooks": merged})
|
|
223
|
+
events = ", ".join(sorted(codex_hooks.keys()))
|
|
224
|
+
print(f"Propagated to Codex: {codex_path} (events: {events})")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _remove_codex(source_name: str, target_dir: str) -> None:
|
|
228
|
+
"""Remove hook entries from ~/.codex/hooks.json."""
|
|
229
|
+
codex_path = _codex_hooks_path(target_dir)
|
|
230
|
+
if not codex_path.is_file():
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
data = load_json(str(codex_path))
|
|
235
|
+
except (json.JSONDecodeError, OSError):
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
existing = data.get("hooks", {})
|
|
239
|
+
cleaned = strip_source(existing, source_name)
|
|
240
|
+
|
|
241
|
+
if cleaned:
|
|
242
|
+
save_json(str(codex_path), {"hooks": cleaned})
|
|
243
|
+
else:
|
|
244
|
+
save_json(str(codex_path), {"hooks": {}})
|
|
245
|
+
|
|
246
|
+
print(f"Removed '{source_name}' from Codex: {codex_path}")
|
|
247
|
+
|
|
248
|
+
|
|
156
249
|
# ---------------------------------------------------------------------------
|
|
157
250
|
# CLI actions
|
|
158
251
|
# ---------------------------------------------------------------------------
|
|
159
252
|
|
|
160
|
-
def
|
|
161
|
-
"""
|
|
253
|
+
def _fetch_and_cache(url: str, source: str) -> str:
|
|
254
|
+
"""Fetch hooks JSON from URL, cache locally, register source.
|
|
162
255
|
|
|
163
256
|
Args:
|
|
164
|
-
|
|
165
|
-
|
|
257
|
+
url: HTTPS URL to fetch.
|
|
258
|
+
source: Source name for caching and registry.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
Path to the cached hooks JSON file.
|
|
166
262
|
"""
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
)
|
|
263
|
+
from url_fetch import fetch_url
|
|
264
|
+
from hook_sources import register_url_source
|
|
265
|
+
from paths import EXTERNAL_HOOKS_DIR
|
|
266
|
+
|
|
267
|
+
EXTERNAL_HOOKS_DIR.mkdir(parents=True, exist_ok=True)
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
data = fetch_url(url)
|
|
271
|
+
except Exception as exc:
|
|
272
|
+
print(f"Error fetching URL: {exc}", file=sys.stderr)
|
|
175
273
|
sys.exit(1)
|
|
176
274
|
|
|
275
|
+
# Validate JSON before caching
|
|
276
|
+
try:
|
|
277
|
+
parsed = json.loads(data)
|
|
278
|
+
except json.JSONDecodeError as exc:
|
|
279
|
+
print(f"Error: URL returned invalid JSON: {exc}", file=sys.stderr)
|
|
280
|
+
sys.exit(2)
|
|
281
|
+
|
|
282
|
+
if "hooks" not in parsed:
|
|
283
|
+
print("Warning: no 'hooks' key found in URL response", file=sys.stderr)
|
|
284
|
+
|
|
285
|
+
cached_path = EXTERNAL_HOOKS_DIR / f"{source}.json"
|
|
286
|
+
cached_path.write_bytes(data)
|
|
287
|
+
register_url_source(None, source, url)
|
|
288
|
+
|
|
289
|
+
return str(cached_path)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def inject(hooks_file: str, target_dir: str, source_override: str = "") -> None:
|
|
293
|
+
"""Inject hooks from *hooks_file* (or URL) into the target settings.json.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
hooks_file: Path to the external hooks JSON file, or an HTTPS URL.
|
|
297
|
+
target_dir: Directory containing ``.claude/settings.json``.
|
|
298
|
+
source_override: Explicit source name (overrides filename-derived name).
|
|
299
|
+
"""
|
|
300
|
+
is_url = _is_url(hooks_file)
|
|
301
|
+
|
|
302
|
+
if is_url:
|
|
303
|
+
if hooks_file.startswith("http://"):
|
|
304
|
+
print(
|
|
305
|
+
"Error: only HTTPS URLs are supported. Use https:// for security.",
|
|
306
|
+
file=sys.stderr,
|
|
307
|
+
)
|
|
308
|
+
sys.exit(1)
|
|
309
|
+
|
|
310
|
+
source = source_override or _name_from_url(hooks_file)
|
|
311
|
+
source = re.sub(r"[^a-zA-Z0-9_-]", "", source)
|
|
312
|
+
if not source:
|
|
313
|
+
print(
|
|
314
|
+
"Error: could not derive hook name from URL. "
|
|
315
|
+
"Provide one explicitly.",
|
|
316
|
+
file=sys.stderr,
|
|
317
|
+
)
|
|
318
|
+
sys.exit(1)
|
|
319
|
+
|
|
320
|
+
if source == PROTECTED_SOURCE:
|
|
321
|
+
print(
|
|
322
|
+
f"Error: source name '{PROTECTED_SOURCE}' is reserved.",
|
|
323
|
+
file=sys.stderr,
|
|
324
|
+
)
|
|
325
|
+
sys.exit(1)
|
|
326
|
+
|
|
327
|
+
hooks_file = _fetch_and_cache(hooks_file, source)
|
|
328
|
+
print(f"Fetched hooks from URL (source: '{source}')")
|
|
329
|
+
else:
|
|
330
|
+
# Derive source name from filename stem
|
|
331
|
+
source = source_override or Path(hooks_file).stem
|
|
332
|
+
if source == PROTECTED_SOURCE:
|
|
333
|
+
print(
|
|
334
|
+
f"Error: source name '{PROTECTED_SOURCE}' is reserved. "
|
|
335
|
+
"Rename your hooks file.",
|
|
336
|
+
file=sys.stderr,
|
|
337
|
+
)
|
|
338
|
+
sys.exit(1)
|
|
339
|
+
|
|
177
340
|
# Load the hooks file
|
|
178
341
|
try:
|
|
179
342
|
hooks_data = load_json(hooks_file)
|
|
@@ -214,10 +377,15 @@ def inject(hooks_file: str, target_dir: str) -> None:
|
|
|
214
377
|
save_json(str(settings_path), settings)
|
|
215
378
|
print(f"Injected hooks from '{source}' into {settings_path}")
|
|
216
379
|
|
|
380
|
+
# Propagate Codex-compatible events to ~/.codex/hooks.json
|
|
381
|
+
_inject_codex(tagged, source, target_dir)
|
|
382
|
+
|
|
217
383
|
|
|
218
384
|
def remove(source_name: str, target_dir: str) -> None:
|
|
219
385
|
"""Remove all hook entries tagged with *source_name*.
|
|
220
386
|
|
|
387
|
+
Also unregisters the URL source if it was URL-sourced.
|
|
388
|
+
|
|
221
389
|
Args:
|
|
222
390
|
source_name: The ``_source`` tag to remove.
|
|
223
391
|
target_dir: Directory containing ``.claude/settings.json``.
|
|
@@ -256,6 +424,24 @@ def remove(source_name: str, target_dir: str) -> None:
|
|
|
256
424
|
save_json(str(settings_path), settings)
|
|
257
425
|
print(f"Removed hooks with source '{source_name}' from {settings_path}")
|
|
258
426
|
|
|
427
|
+
# Remove from Codex global hooks
|
|
428
|
+
_remove_codex(source_name, target_dir)
|
|
429
|
+
|
|
430
|
+
# Unregister URL source if present
|
|
431
|
+
try:
|
|
432
|
+
from hook_sources import unregister_source
|
|
433
|
+
from paths import EXTERNAL_HOOKS_DIR
|
|
434
|
+
|
|
435
|
+
if unregister_source(None, source_name):
|
|
436
|
+
print(f"Unregistered URL source '{source_name}'")
|
|
437
|
+
|
|
438
|
+
# Remove cached file if exists
|
|
439
|
+
cached = EXTERNAL_HOOKS_DIR / f"{source_name}.json"
|
|
440
|
+
if cached.is_file():
|
|
441
|
+
cached.unlink()
|
|
442
|
+
except ImportError:
|
|
443
|
+
pass
|
|
444
|
+
|
|
259
445
|
|
|
260
446
|
# ---------------------------------------------------------------------------
|
|
261
447
|
# Argument parsing
|
|
@@ -265,16 +451,19 @@ def _parse_args(argv: list[str]) -> dict:
|
|
|
265
451
|
"""Parse CLI arguments.
|
|
266
452
|
|
|
267
453
|
Returns:
|
|
268
|
-
Dict with keys: remove_mode, remove_name, source_file,
|
|
454
|
+
Dict with keys: remove_mode, remove_name, source_file, hook_name,
|
|
455
|
+
target_dir.
|
|
269
456
|
"""
|
|
270
457
|
result: dict = {
|
|
271
458
|
"remove_mode": False,
|
|
272
459
|
"remove_name": "",
|
|
273
460
|
"source_file": "",
|
|
461
|
+
"hook_name": "",
|
|
274
462
|
"target_dir": str(Path.home()),
|
|
275
463
|
}
|
|
276
464
|
|
|
277
465
|
i = 0
|
|
466
|
+
positional = 0
|
|
278
467
|
while i < len(argv):
|
|
279
468
|
arg = argv[i]
|
|
280
469
|
if arg == "--remove":
|
|
@@ -287,10 +476,25 @@ def _parse_args(argv: list[str]) -> dict:
|
|
|
287
476
|
elif arg.startswith("-"):
|
|
288
477
|
print(f"Unknown option: {arg}", file=sys.stderr)
|
|
289
478
|
sys.exit(1)
|
|
290
|
-
elif not result["source_file"] and not result["remove_mode"]:
|
|
291
|
-
result["source_file"] = arg
|
|
292
479
|
else:
|
|
293
|
-
|
|
480
|
+
if positional == 0:
|
|
481
|
+
if not result["remove_mode"]:
|
|
482
|
+
result["source_file"] = arg
|
|
483
|
+
else:
|
|
484
|
+
result["target_dir"] = arg
|
|
485
|
+
elif positional == 1:
|
|
486
|
+
if _is_url(result["source_file"]):
|
|
487
|
+
# Second positional after URL could be hook-name or target-dir
|
|
488
|
+
# If it looks like a path (starts with / or ~ or .), it's target-dir
|
|
489
|
+
if arg.startswith(("/", "~", ".")):
|
|
490
|
+
result["target_dir"] = arg
|
|
491
|
+
else:
|
|
492
|
+
result["hook_name"] = arg
|
|
493
|
+
else:
|
|
494
|
+
result["target_dir"] = arg
|
|
495
|
+
elif positional == 2:
|
|
496
|
+
result["target_dir"] = arg
|
|
497
|
+
positional += 1
|
|
294
498
|
i += 1
|
|
295
499
|
|
|
296
500
|
return result
|
|
@@ -309,7 +513,7 @@ def main() -> None:
|
|
|
309
513
|
source_file = args["source_file"]
|
|
310
514
|
if not source_file:
|
|
311
515
|
print(
|
|
312
|
-
"Usage: inject_hook_cli.py <hooks-file
|
|
516
|
+
"Usage: inject_hook_cli.py <hooks-file-or-url> [hook-name] [target-dir]",
|
|
313
517
|
file=sys.stderr,
|
|
314
518
|
)
|
|
315
519
|
print(
|
|
@@ -318,12 +522,13 @@ def main() -> None:
|
|
|
318
522
|
)
|
|
319
523
|
sys.exit(1)
|
|
320
524
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
525
|
+
if not _is_url(source_file):
|
|
526
|
+
source_path = Path(source_file)
|
|
527
|
+
if not source_path.is_file():
|
|
528
|
+
print(f"Hooks file not found: {source_path}", file=sys.stderr)
|
|
529
|
+
sys.exit(1)
|
|
325
530
|
|
|
326
|
-
inject(source_file, args["target_dir"])
|
|
531
|
+
inject(source_file, args["target_dir"], source_override=args["hook_name"])
|
|
327
532
|
|
|
328
533
|
|
|
329
534
|
if __name__ == "__main__":
|
package/scripts/install.py
CHANGED
|
@@ -47,14 +47,17 @@ from emission import agent_count as count_agents, skill_count as count_skills
|
|
|
47
47
|
# Step modules
|
|
48
48
|
from install_steps.symlinks import install_agents, install_skills, clean_legacy_commands
|
|
49
49
|
from install_steps.hooks import install_hooks
|
|
50
|
-
from install_steps.markers import install_marker_files, inject_rules
|
|
50
|
+
from install_steps.markers import install_marker_files, inject_rules, refresh_url_hooks
|
|
51
51
|
from install_steps.ai_tools import install_ai_tools, install_local_project, run_script
|
|
52
52
|
from install_steps.install_state import (
|
|
53
53
|
load_state,
|
|
54
54
|
record_install,
|
|
55
55
|
get_installed_modules,
|
|
56
56
|
get_installed_profile,
|
|
57
|
+
get_global_editors,
|
|
58
|
+
record_global_editors,
|
|
57
59
|
print_status,
|
|
60
|
+
GLOBAL_CAPABLE_EDITORS,
|
|
58
61
|
)
|
|
59
62
|
from install_steps.detect_language import detect_languages
|
|
60
63
|
from install_steps.project_registry import register_project
|
|
@@ -432,6 +435,9 @@ def install_claude_code(target_dir: Path, hooks_scripts_dir: Path,
|
|
|
432
435
|
inject_rules(claude_dir, target_dir, rules_dir, only, skip, dry_run,
|
|
433
436
|
refresh_urls=True)
|
|
434
437
|
|
|
438
|
+
if not dry_run:
|
|
439
|
+
refresh_url_hooks(str(target_dir))
|
|
440
|
+
|
|
435
441
|
_sync_mcp_templates(dry_run)
|
|
436
442
|
|
|
437
443
|
|
|
@@ -705,17 +711,31 @@ def main() -> None:
|
|
|
705
711
|
profile = cfg["profile"]
|
|
706
712
|
|
|
707
713
|
lang_modules = [m for m in (resolved_modules or []) if m.startswith("rules-")]
|
|
708
|
-
|
|
714
|
+
local_editors_arg: str = cfg["editors"]
|
|
709
715
|
install_local_project(rules_dir, dry_run, reset, lang_modules or None,
|
|
710
|
-
editors=
|
|
716
|
+
editors=local_editors_arg,
|
|
711
717
|
merged_config=merged_config)
|
|
718
|
+
installed_eds: list[str] = [] # local install doesn't track global editors
|
|
712
719
|
install_strict_git_hooks(profile, local, dry_run)
|
|
713
720
|
else:
|
|
714
721
|
# Global install
|
|
715
722
|
print_banner(target_dir, rules_dir, profile, only, skip, dry_run,
|
|
716
723
|
modules=resolved_modules)
|
|
717
724
|
install_claude_code(target_dir, hooks_scripts_dir, rules_dir, only, skip, dry_run)
|
|
718
|
-
|
|
725
|
+
|
|
726
|
+
# Determine global editors: --editors flag > state > default (none)
|
|
727
|
+
editors_arg: str = cfg["editors"]
|
|
728
|
+
if editors_arg:
|
|
729
|
+
if editors_arg == "all":
|
|
730
|
+
global_eds = list(GLOBAL_CAPABLE_EDITORS)
|
|
731
|
+
else:
|
|
732
|
+
global_eds = [e.strip() for e in editors_arg.split(",") if e.strip()]
|
|
733
|
+
else:
|
|
734
|
+
# On update: use editors from state; on fresh install: none
|
|
735
|
+
global_eds = get_global_editors() or None
|
|
736
|
+
|
|
737
|
+
installed_eds = install_ai_tools(target_dir, rules_dir, dry_run,
|
|
738
|
+
editors=global_eds)
|
|
719
739
|
install_persona(target_dir, persona, dry_run)
|
|
720
740
|
install_strict_git_hooks(profile, local, dry_run)
|
|
721
741
|
|
|
@@ -746,6 +766,10 @@ def main() -> None:
|
|
|
746
766
|
extends_info=extends_info,
|
|
747
767
|
)
|
|
748
768
|
|
|
769
|
+
# Record global editors (only for global install, not --local)
|
|
770
|
+
if not local and installed_eds:
|
|
771
|
+
record_global_editors(installed_eds)
|
|
772
|
+
|
|
749
773
|
# Register project in global registry (for `ai-toolkit update` propagation)
|
|
750
774
|
# Skipped when called from update_projects.py (--skip-register) to avoid
|
|
751
775
|
# concurrent writes to projects.json during parallel updates.
|
|
@@ -753,10 +777,20 @@ def main() -> None:
|
|
|
753
777
|
extends_source = ""
|
|
754
778
|
if extends_info:
|
|
755
779
|
extends_source = extends_info.get("source", "")
|
|
780
|
+
# Determine editors to record for this project
|
|
781
|
+
local_eds_for_registry: list[str] | None = None
|
|
782
|
+
if local and local_editors_arg:
|
|
783
|
+
if local_editors_arg == "all":
|
|
784
|
+
from install_steps.ai_tools import ALL_EDITORS
|
|
785
|
+
local_eds_for_registry = list(ALL_EDITORS)
|
|
786
|
+
else:
|
|
787
|
+
local_eds_for_registry = [e.strip() for e in local_editors_arg.split(",") if e.strip()]
|
|
788
|
+
|
|
756
789
|
is_new = register_project(
|
|
757
790
|
project_dir,
|
|
758
791
|
profile=profile or "standard",
|
|
759
792
|
extends=extends_source,
|
|
793
|
+
editors=local_eds_for_registry,
|
|
760
794
|
)
|
|
761
795
|
if is_new:
|
|
762
796
|
print(f" Registered project in {TOOLKIT_DATA_DIR / 'projects.json'}")
|