@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
|
@@ -19,50 +19,113 @@ from injection import (
|
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
def install_ai_tools(target_dir: Path, rules_dir: Path,
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
dry_run: bool,
|
|
23
|
+
editors: list[str] | None = None) -> list[str]:
|
|
24
|
+
"""Install global editor configs.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
editors: Explicit list of editors to install globally. If None,
|
|
28
|
+
uses DEFAULT_GLOBAL_EDITORS (empty = Claude only).
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
List of editors that were actually installed (for state tracking).
|
|
32
|
+
"""
|
|
33
|
+
from install_steps.install_state import DEFAULT_GLOBAL_EDITORS, GLOBAL_CAPABLE_EDITORS
|
|
34
|
+
|
|
35
|
+
if editors is None:
|
|
36
|
+
eds = set(DEFAULT_GLOBAL_EDITORS)
|
|
37
|
+
else:
|
|
38
|
+
eds = set(editors)
|
|
39
|
+
|
|
40
|
+
# Filter to only globally-capable editors
|
|
41
|
+
eds = eds & set(GLOBAL_CAPABLE_EDITORS)
|
|
42
|
+
|
|
43
|
+
if not eds:
|
|
44
|
+
return []
|
|
45
|
+
|
|
24
46
|
print()
|
|
25
47
|
print("## Other AI Tools (global)")
|
|
26
48
|
print()
|
|
27
49
|
|
|
28
|
-
|
|
50
|
+
installed: list[str] = []
|
|
51
|
+
|
|
52
|
+
# Editors are opt-in via --editors, not filtered by --only/--skip (those
|
|
53
|
+
# control Claude components like agents, hooks, rules). If an editor is
|
|
54
|
+
# in the requested set, install it unconditionally.
|
|
55
|
+
|
|
56
|
+
if "cursor" in eds:
|
|
29
57
|
cursor_file = target_dir / ".cursor" / "rules"
|
|
30
58
|
if dry_run:
|
|
31
59
|
print(" Would inject: ~/.cursor/rules")
|
|
32
60
|
else:
|
|
33
61
|
inject_with_rules("generate-cursor-rules.sh", cursor_file, rules_dir)
|
|
34
|
-
|
|
35
|
-
print(" Skipped: cursor")
|
|
62
|
+
installed.append("cursor")
|
|
36
63
|
|
|
37
|
-
if
|
|
64
|
+
if "windsurf" in eds:
|
|
38
65
|
windsurf_file = target_dir / ".codeium" / "windsurf" / "memories" / "global_rules.md"
|
|
39
66
|
if dry_run:
|
|
40
67
|
print(" Would inject: ~/.codeium/windsurf/memories/global_rules.md")
|
|
41
68
|
else:
|
|
42
69
|
inject_with_rules("generate-windsurf.sh", windsurf_file, rules_dir)
|
|
43
|
-
|
|
44
|
-
print(" Skipped: windsurf")
|
|
70
|
+
installed.append("windsurf")
|
|
45
71
|
|
|
46
|
-
if
|
|
72
|
+
if "gemini" in eds:
|
|
47
73
|
gemini_file = target_dir / ".gemini" / "GEMINI.md"
|
|
48
74
|
if dry_run:
|
|
49
75
|
print(" Would inject: ~/.gemini/GEMINI.md")
|
|
50
76
|
else:
|
|
51
77
|
inject_with_rules("generate-gemini.sh", gemini_file, rules_dir)
|
|
52
|
-
|
|
53
|
-
print(" Skipped: gemini")
|
|
78
|
+
installed.append("gemini")
|
|
54
79
|
|
|
55
|
-
if
|
|
80
|
+
if "augment" in eds:
|
|
56
81
|
augment_file = target_dir / ".augment" / "rules" / "ai-toolkit.md"
|
|
57
82
|
if dry_run:
|
|
58
83
|
print(" Would inject: ~/.augment/rules/ai-toolkit.md")
|
|
59
84
|
else:
|
|
60
85
|
inject_with_rules("generate-augment.sh", augment_file, rules_dir)
|
|
61
|
-
|
|
62
|
-
|
|
86
|
+
installed.append("augment")
|
|
87
|
+
|
|
88
|
+
if "codex" in eds:
|
|
89
|
+
if dry_run:
|
|
90
|
+
print(" Would inject: ~/AGENTS.md, ~/.agents/, ~/.codex/hooks.json")
|
|
91
|
+
else:
|
|
92
|
+
_install_codex_global(target_dir, rules_dir)
|
|
93
|
+
installed.append("codex")
|
|
63
94
|
|
|
64
95
|
print()
|
|
65
|
-
print("
|
|
96
|
+
print(f" Available: {', '.join(GLOBAL_CAPABLE_EDITORS)}")
|
|
97
|
+
print(" Note: Copilot, Cline, Roo Code, Aider, Antigravity have no global config -- use 'ai-toolkit install --local' per project")
|
|
98
|
+
|
|
99
|
+
return installed
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _install_codex_global(target_dir: Path, rules_dir: Path) -> None:
|
|
103
|
+
"""Install Codex at the global level (~/ layer).
|
|
104
|
+
|
|
105
|
+
Creates:
|
|
106
|
+
- ~/AGENTS.md (marker injection with rules)
|
|
107
|
+
- ~/.agents/rules/*.md (directory-based rules)
|
|
108
|
+
- ~/.agents/skills/* (skill symlinks)
|
|
109
|
+
- ~/.codex/hooks.json (lifecycle hooks)
|
|
110
|
+
"""
|
|
111
|
+
inject_with_rules(
|
|
112
|
+
"generate_codex.py",
|
|
113
|
+
target_dir / "AGENTS.md",
|
|
114
|
+
rules_dir,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
from generate_codex_rules import generate as gen_codex_rules
|
|
118
|
+
gen_codex_rules(
|
|
119
|
+
target_dir,
|
|
120
|
+
rules_dir=rules_dir,
|
|
121
|
+
managed_scopes=("standard", "custom"),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
from generate_codex_hooks import generate as gen_codex_hooks
|
|
125
|
+
gen_codex_hooks(target_dir)
|
|
126
|
+
print(" Created: ~/.codex/hooks.json")
|
|
127
|
+
|
|
128
|
+
_install_codex_skills(target_dir)
|
|
66
129
|
|
|
67
130
|
|
|
68
131
|
def inject_with_rules(
|
|
@@ -85,6 +85,27 @@ def remove_mcp_template(name: str) -> None:
|
|
|
85
85
|
save_state(state)
|
|
86
86
|
|
|
87
87
|
|
|
88
|
+
# Default global install: Claude only — no other editors unless --editors is used
|
|
89
|
+
DEFAULT_GLOBAL_EDITORS: list[str] = []
|
|
90
|
+
|
|
91
|
+
# All editors that support global install (opt-in via --editors)
|
|
92
|
+
GLOBAL_CAPABLE_EDITORS = ["augment", "codex", "cursor", "gemini", "windsurf"]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_global_editors() -> list[str]:
|
|
96
|
+
"""Return list of globally installed editor names from state."""
|
|
97
|
+
state = load_state()
|
|
98
|
+
editors = state.get("global_editors", [])
|
|
99
|
+
return editors if isinstance(editors, list) else []
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def record_global_editors(editors: list[str]) -> None:
|
|
103
|
+
"""Record which editors are installed globally in state.json."""
|
|
104
|
+
state = load_state()
|
|
105
|
+
state["global_editors"] = sorted(set(editors))
|
|
106
|
+
save_state(state)
|
|
107
|
+
|
|
108
|
+
|
|
88
109
|
def _now_iso() -> str:
|
|
89
110
|
"""Return current UTC time in ISO 8601 format."""
|
|
90
111
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
@@ -163,6 +184,10 @@ def print_status() -> None:
|
|
|
163
184
|
langs = [m.replace("rules-", "") for m in detected]
|
|
164
185
|
print(f" Detected: {', '.join(langs)}")
|
|
165
186
|
|
|
187
|
+
editors = state.get("global_editors", [])
|
|
188
|
+
if editors:
|
|
189
|
+
print(f" Editors: {', '.join(editors)}")
|
|
190
|
+
|
|
166
191
|
mcp = state.get("mcp_templates", [])
|
|
167
192
|
if mcp:
|
|
168
193
|
print(f" MCP: {', '.join(mcp)}")
|
|
@@ -99,6 +99,48 @@ def _refresh_url_rules(rules_dir: Path) -> None:
|
|
|
99
99
|
print(f" No cached version — rule will be skipped.")
|
|
100
100
|
|
|
101
101
|
|
|
102
|
+
def refresh_url_hooks(target_dir: str | None = None) -> None:
|
|
103
|
+
"""Re-fetch all URL-sourced hooks and re-inject them.
|
|
104
|
+
|
|
105
|
+
Called during ``ai-toolkit update`` to keep URL-sourced hooks current.
|
|
106
|
+
On fetch failure, warns and keeps the cached version.
|
|
107
|
+
"""
|
|
108
|
+
from hook_sources import get_url_hooks, register_url_source
|
|
109
|
+
from paths import EXTERNAL_HOOKS_DIR
|
|
110
|
+
from url_fetch import fetch_url
|
|
111
|
+
import json
|
|
112
|
+
|
|
113
|
+
url_hooks = get_url_hooks()
|
|
114
|
+
if not url_hooks:
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
print(" Refreshing URL-sourced hooks...")
|
|
118
|
+
target = target_dir or str(Path.home())
|
|
119
|
+
|
|
120
|
+
for hook_name, url in url_hooks.items():
|
|
121
|
+
cached_file = EXTERNAL_HOOKS_DIR / f"{hook_name}.json"
|
|
122
|
+
try:
|
|
123
|
+
data = fetch_url(url)
|
|
124
|
+
# Validate JSON before caching
|
|
125
|
+
json.loads(data)
|
|
126
|
+
cached_file.write_bytes(data)
|
|
127
|
+
register_url_source(None, hook_name, url)
|
|
128
|
+
print(f" Refreshed: {hook_name} (from {url})")
|
|
129
|
+
except Exception as exc:
|
|
130
|
+
if cached_file.is_file():
|
|
131
|
+
print(f" Warning: could not refresh '{hook_name}' from {url}: {exc}")
|
|
132
|
+
print(f" Using cached version.")
|
|
133
|
+
else:
|
|
134
|
+
print(f" Warning: could not fetch '{hook_name}' from {url}: {exc}")
|
|
135
|
+
print(f" No cached version — hook will be skipped.")
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
# Re-inject from cached file
|
|
139
|
+
if cached_file.is_file():
|
|
140
|
+
from inject_hook_cli import inject
|
|
141
|
+
inject(str(cached_file), target, source_override=hook_name)
|
|
142
|
+
|
|
143
|
+
|
|
102
144
|
def _inject_rules_dry_run(rules_dir: Path) -> None:
|
|
103
145
|
rules_src = app_dir / "rules"
|
|
104
146
|
rule_names = " ".join(
|
|
@@ -130,11 +130,17 @@ def register_project(
|
|
|
130
130
|
project_path: str | Path,
|
|
131
131
|
profile: str = "",
|
|
132
132
|
extends: str = "",
|
|
133
|
+
editors: list[str] | None = None,
|
|
133
134
|
) -> bool:
|
|
134
135
|
"""Register a project directory. Returns True if newly added, False if updated.
|
|
135
136
|
|
|
136
137
|
Idempotent — updates existing entry if path already registered.
|
|
137
138
|
Uses file lock to prevent concurrent read-modify-write races.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
editors: List of editors installed locally (e.g. ["codex", "cursor"]).
|
|
142
|
+
If provided, replaces the stored editors list. If None, keeps
|
|
143
|
+
existing editors (or empty for new projects).
|
|
138
144
|
"""
|
|
139
145
|
project_path = str(Path(project_path).resolve())
|
|
140
146
|
|
|
@@ -153,6 +159,8 @@ def register_project(
|
|
|
153
159
|
elif "extends" in p and not extends:
|
|
154
160
|
# Clear extends if project no longer uses it
|
|
155
161
|
pass
|
|
162
|
+
if editors is not None:
|
|
163
|
+
p["editors"] = sorted(set(editors))
|
|
156
164
|
save_registry(projects)
|
|
157
165
|
return False
|
|
158
166
|
|
|
@@ -163,6 +171,7 @@ def register_project(
|
|
|
163
171
|
"last_updated": now,
|
|
164
172
|
"profile": profile or "standard",
|
|
165
173
|
"extends": extends or "",
|
|
174
|
+
"editors": sorted(set(editors)) if editors else [],
|
|
166
175
|
})
|
|
167
176
|
save_registry(projects)
|
|
168
177
|
return True
|
package/scripts/paths.py
CHANGED
|
@@ -25,6 +25,7 @@ LEGACY_DATA_DIR = Path.home() / ".ai-toolkit"
|
|
|
25
25
|
|
|
26
26
|
# Sub-directories under TOOLKIT_DATA_DIR
|
|
27
27
|
HOOKS_DIR = TOOLKIT_DATA_DIR / "hooks"
|
|
28
|
+
EXTERNAL_HOOKS_DIR = HOOKS_DIR / "external"
|
|
28
29
|
RULES_DIR = TOOLKIT_DATA_DIR / "rules"
|
|
29
30
|
SESSIONS_DIR = TOOLKIT_DATA_DIR / "sessions"
|
|
30
31
|
COMPACTIONS_DIR = TOOLKIT_DATA_DIR / "compactions"
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Propagate rules, hooks, and MCP configs to all globally installed editors.
|
|
3
|
+
|
|
4
|
+
Called automatically after inject-rule, inject-hook, add-rule, remove-rule,
|
|
5
|
+
and mcp add to keep global editor configs in sync.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
propagate_global.py [--rules] [--hooks] [--mcp]
|
|
9
|
+
|
|
10
|
+
Flags (can combine):
|
|
11
|
+
--rules Re-inject registered rules into global editor configs
|
|
12
|
+
--hooks Re-inject external hooks into Codex global hooks.json
|
|
13
|
+
--mcp Sync MCP templates to global editor MCP configs
|
|
14
|
+
|
|
15
|
+
With no flags, propagates rules (the most common case).
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def propagate_rules() -> None:
|
|
26
|
+
"""Re-inject registered rules into all global editors from state."""
|
|
27
|
+
from paths import RULES_DIR
|
|
28
|
+
from install_steps.install_state import get_global_editors
|
|
29
|
+
|
|
30
|
+
editors = get_global_editors()
|
|
31
|
+
if not editors:
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
target_dir = Path.home()
|
|
35
|
+
rules_dir = RULES_DIR
|
|
36
|
+
|
|
37
|
+
from install_steps.ai_tools import install_ai_tools
|
|
38
|
+
print("Propagating rules to global editors...")
|
|
39
|
+
install_ai_tools(target_dir, rules_dir, dry_run=False, editors=editors)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def propagate_hooks() -> None:
|
|
43
|
+
"""Re-inject URL-sourced hooks into Codex global hooks.json."""
|
|
44
|
+
from install_steps.install_state import get_global_editors
|
|
45
|
+
|
|
46
|
+
editors = get_global_editors()
|
|
47
|
+
if "codex" not in editors:
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
# Hooks are already propagated to Codex by inject_hook_cli.py
|
|
51
|
+
# This is a no-op — kept for completeness and future editors with hooks
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def propagate_mcp() -> None:
|
|
56
|
+
"""Sync globally tracked MCP templates to global editor MCP configs."""
|
|
57
|
+
from install_steps.install_state import get_global_editors, get_mcp_templates
|
|
58
|
+
|
|
59
|
+
editors = get_global_editors()
|
|
60
|
+
templates = get_mcp_templates()
|
|
61
|
+
if not editors or not templates:
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
# MCP editor sync is handled by mcp_manager.py install --editor --scope global
|
|
65
|
+
import subprocess
|
|
66
|
+
scripts_dir = Path(__file__).resolve().parent
|
|
67
|
+
|
|
68
|
+
for editor in editors:
|
|
69
|
+
try:
|
|
70
|
+
subprocess.run(
|
|
71
|
+
["python3", str(scripts_dir / "mcp_manager.py"),
|
|
72
|
+
"install", "--editor", editor, "--scope", "global"] + templates,
|
|
73
|
+
capture_output=True, text=True, timeout=30,
|
|
74
|
+
)
|
|
75
|
+
print(f" MCP synced to {editor} (global)")
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
print(f" Warning: MCP sync to {editor} failed: {exc}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def main() -> None:
|
|
81
|
+
args = set(sys.argv[1:])
|
|
82
|
+
|
|
83
|
+
if not args or "--rules" in args:
|
|
84
|
+
propagate_rules()
|
|
85
|
+
if "--hooks" in args:
|
|
86
|
+
propagate_hooks()
|
|
87
|
+
if "--mcp" in args:
|
|
88
|
+
propagate_mcp()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
main()
|
package/scripts/rule_sources.py
CHANGED
|
@@ -12,21 +12,17 @@ from __future__ import annotations
|
|
|
12
12
|
|
|
13
13
|
import json
|
|
14
14
|
import os
|
|
15
|
-
import ssl
|
|
16
15
|
import sys
|
|
17
16
|
import tempfile
|
|
18
|
-
import urllib.request
|
|
19
|
-
import urllib.error
|
|
20
17
|
from datetime import datetime, timezone
|
|
21
18
|
from pathlib import Path
|
|
22
19
|
from typing import Any
|
|
23
20
|
|
|
24
21
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
25
22
|
from paths import RULES_DIR
|
|
23
|
+
from url_fetch import fetch_url as fetch_url # noqa: F811 — re-export
|
|
26
24
|
|
|
27
25
|
_SOURCES_FILENAME = "sources.json"
|
|
28
|
-
_FETCH_TIMEOUT = 30 # seconds
|
|
29
|
-
_FETCH_MAX_BYTES = 10 * 1024 * 1024 # 10MB
|
|
30
26
|
|
|
31
27
|
|
|
32
28
|
# ---------------------------------------------------------------------------
|
|
@@ -85,6 +81,9 @@ def save_sources(rules_dir: Path | None = None,
|
|
|
85
81
|
|
|
86
82
|
def register_url_source(rules_dir: Path | None, rule_name: str, url: str) -> None:
|
|
87
83
|
"""Add or update a URL source entry."""
|
|
84
|
+
import re
|
|
85
|
+
if not rule_name or not re.fullmatch(r"[a-zA-Z0-9_-]+", rule_name):
|
|
86
|
+
raise ValueError(f"Invalid rule name: {rule_name!r}")
|
|
88
87
|
rules_dir = rules_dir or RULES_DIR
|
|
89
88
|
sources = load_sources(rules_dir)
|
|
90
89
|
sources[rule_name] = {
|
|
@@ -112,27 +111,5 @@ def get_url_rules(rules_dir: Path | None = None) -> dict[str, str]:
|
|
|
112
111
|
|
|
113
112
|
|
|
114
113
|
# ---------------------------------------------------------------------------
|
|
115
|
-
# Fetch
|
|
114
|
+
# Fetch — delegated to shared url_fetch module (re-exported above)
|
|
116
115
|
# ---------------------------------------------------------------------------
|
|
117
|
-
|
|
118
|
-
def fetch_url(url: str) -> bytes:
|
|
119
|
-
"""Fetch URL content. HTTPS only, 30s timeout, 10MB cap.
|
|
120
|
-
|
|
121
|
-
Raises:
|
|
122
|
-
ValueError: if URL is not HTTPS
|
|
123
|
-
urllib.error.URLError: on network failure
|
|
124
|
-
"""
|
|
125
|
-
if not url.startswith("https://"):
|
|
126
|
-
raise ValueError(
|
|
127
|
-
f"Only HTTPS URLs are supported (got: {url.split('://')[0]}://)"
|
|
128
|
-
)
|
|
129
|
-
|
|
130
|
-
ctx = ssl.create_default_context()
|
|
131
|
-
with urllib.request.urlopen(url, timeout=_FETCH_TIMEOUT, context=ctx) as resp:
|
|
132
|
-
data = resp.read(_FETCH_MAX_BYTES)
|
|
133
|
-
|
|
134
|
-
# Basic binary detection — reject if null bytes present
|
|
135
|
-
if b"\x00" in data:
|
|
136
|
-
raise ValueError(f"URL returned binary content, expected markdown: {url}")
|
|
137
|
-
|
|
138
|
-
return data
|
|
@@ -25,9 +25,15 @@ def _update_project(project: dict[str, Any], install_script: str, extra_args: li
|
|
|
25
25
|
project_path = project["path"]
|
|
26
26
|
start = time.monotonic()
|
|
27
27
|
|
|
28
|
+
# Pass saved editors from registry so update re-installs the same editors
|
|
29
|
+
cmd_args = ["python3", install_script, "--local"] + extra_args
|
|
30
|
+
project_editors = project.get("editors", [])
|
|
31
|
+
if project_editors:
|
|
32
|
+
cmd_args.extend(["--editors", ",".join(project_editors)])
|
|
33
|
+
|
|
28
34
|
try:
|
|
29
35
|
proc = subprocess.run(
|
|
30
|
-
|
|
36
|
+
cmd_args,
|
|
31
37
|
cwd=project_path,
|
|
32
38
|
capture_output=True,
|
|
33
39
|
text=True,
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared URL fetch utility for ai-toolkit.
|
|
3
|
+
|
|
4
|
+
HTTPS-only, timeout-capped, size-limited fetcher used by both
|
|
5
|
+
rule_sources and hook_sources.
|
|
6
|
+
|
|
7
|
+
Stdlib-only — no external dependencies.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ssl
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.request
|
|
14
|
+
|
|
15
|
+
_FETCH_TIMEOUT = 30 # seconds
|
|
16
|
+
_FETCH_MAX_BYTES = 10 * 1024 * 1024 # 10MB
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def fetch_url(url: str) -> bytes:
|
|
20
|
+
"""Fetch URL content. HTTPS only, 30s timeout, 10MB cap.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
url: The HTTPS URL to fetch.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Raw bytes of the response body.
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
ValueError: if URL is not HTTPS or returns binary content.
|
|
30
|
+
urllib.error.URLError: on network failure.
|
|
31
|
+
"""
|
|
32
|
+
if not url.startswith("https://"):
|
|
33
|
+
raise ValueError(
|
|
34
|
+
f"Only HTTPS URLs are supported (got: {url.split('://')[0]}://)"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
ctx = ssl.create_default_context()
|
|
38
|
+
with urllib.request.urlopen(url, timeout=_FETCH_TIMEOUT, context=ctx) as resp:
|
|
39
|
+
data = resp.read(_FETCH_MAX_BYTES)
|
|
40
|
+
# Detect truncation — if there's more data, the response exceeds the limit
|
|
41
|
+
if resp.read(1):
|
|
42
|
+
raise ValueError(
|
|
43
|
+
f"Response exceeds {_FETCH_MAX_BYTES} byte limit: {url}"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Basic binary detection — reject if null bytes present
|
|
47
|
+
if b"\x00" in data:
|
|
48
|
+
raise ValueError(f"URL returned binary content: {url}")
|
|
49
|
+
|
|
50
|
+
return data
|