@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.
@@ -19,50 +19,113 @@ from injection import (
19
19
 
20
20
 
21
21
  def install_ai_tools(target_dir: Path, rules_dir: Path,
22
- only: str, skip: str, dry_run: bool) -> None:
23
- """Install Cursor, Windsurf, Gemini global configs."""
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
- if should_install("cursor", only, skip):
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
- else:
35
- print(" Skipped: cursor")
62
+ installed.append("cursor")
36
63
 
37
- if should_install("windsurf", only, skip):
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
- else:
44
- print(" Skipped: windsurf")
70
+ installed.append("windsurf")
45
71
 
46
- if should_install("gemini", only, skip):
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
- else:
53
- print(" Skipped: gemini")
78
+ installed.append("gemini")
54
79
 
55
- if should_install("augment", only, skip):
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
- else:
62
- print(" Skipped: augment")
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(" Note: Copilot, Cline, Roo Code, and Aider have no global config -- use 'ai-toolkit install --local' per project")
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()
@@ -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
- ["python3", install_script, "--local"] + extra_args,
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