@softspark/ai-toolkit 2.4.0 → 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 CHANGED
@@ -7,6 +7,28 @@ Versioning follows [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
+ ## v2.4.1 — Codex Global Install, Security Hardening & Editor Tracking (2026-04-15)
11
+
12
+ ### Added
13
+ - **Codex hook propagation** — `inject-hook` auto-propagates Codex-compatible events (`SessionStart`, `PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `Stop`) to `~/.codex/hooks.json`. `remove-hook` cleans both targets.
14
+ - **Global editor tracking** — `ai-toolkit install --editors codex` installs editors globally (opt-in). Tracked in `state.json`, auto-refreshed on `update`. Default: Claude only.
15
+ - **Per-project editor tracking** — `install --local --editors` records editors in `projects.json`. `update` re-installs saved editors per project.
16
+ - **Auto-propagation** — `add-rule`, `remove-rule`, `mcp add` auto-propagate to globally installed editors via `propagate_global.py`.
17
+ - **Doctor Check 9** — URL hook sources health check with `--fix` re-fetch support.
18
+
19
+ ### Fixed
20
+ - **Tarball path traversal** — `_extract_tarball` rejects symlinks, absolute paths, and paths escaping dest directory.
21
+ - **Git clone URL validation** — `_resolve_git` enforces HTTPS-only, rejects `file://`, `ssh://`.
22
+ - **`dr.error()` crash** — `DiagResult` has no `error()` method; fixed to `dr.fail()`.
23
+ - **`refresh_url_hooks` missing timestamp update** — now calls `register_url_source` after re-fetch.
24
+ - **Defense-in-depth name validation** — `register_url_source()` rejects path traversal chars in both `hook_sources` and `rule_sources`.
25
+ - **Unused `execSync` import** — removed from CLI entry point.
26
+ - **URL truncation detection** — `url_fetch.py` raises `ValueError` when response exceeds 10MB.
27
+ - **MCP templates path** — corrected `app/plugins/mcp-templates/` → `app/mcp-templates/` in ARCHITECTURE docs.
28
+ - **MCP templates header count** — corrected 25 → 26 in `app/ARCHITECTURE.md`.
29
+
30
+ ---
31
+
10
32
  ## v2.4.0 — URL Hook Injection & Karpathy Coding Rules (2026-04-15)
11
33
 
12
34
  ### Added
package/README.md CHANGED
@@ -6,16 +6,17 @@
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
7
7
  [![Skills](https://img.shields.io/badge/skills-92-brightgreen)](app/skills/)
8
8
  [![Agents](https://img.shields.io/badge/agents-44-blue)](app/agents/)
9
- [![Tests](https://img.shields.io/badge/tests-613%20passing-success)](tests/)
9
+ [![Tests](https://img.shields.io/badge/tests-618%20passing-success)](tests/)
10
10
 
11
11
  ---
12
12
 
13
- ## What's New in v2.4.0
13
+ ## What's New in v2.4.1
14
14
 
15
- - **URL hook injection** — `ai-toolkit inject-hook https://...` with auto-refresh on every update (mirrors `add-rule` URL support)
16
- - **Shared URL fetch** — extracted `url_fetch.py` for reuse across rule and hook URL sources
17
- - **Surgical Changes rule** orphan cleanup protocol and "match existing style" in `common/coding-style.md`
18
- - **Goal-Driven Execution rule** — `step verify: check` pattern for multi-step tasks
15
+ - **Global editor tracking** — `ai-toolkit install --editors codex` installs Codex globally, tracked in `state.json`, auto-refreshed on `update`
16
+ - **Codex hook propagation** — `inject-hook` auto-propagates to `~/.codex/hooks.json` for Codex-compatible events
17
+ - **Auto-propagation** `add-rule`, `remove-rule`, `mcp add` auto-propagate to all globally installed editors
18
+ - **Security hardening** — tarball path traversal fix, git URL scheme validation, defense-in-depth name validation
19
+ - **Doctor Check 9** — URL hook sources health check with `--fix` re-fetch support
19
20
 
20
21
  See [CHANGELOG.md](CHANGELOG.md) for full history.
21
22
 
@@ -142,7 +143,7 @@ ai-toolkit/
142
143
  │ └── ARCHITECTURE.md # Full system design
143
144
  ├── kb/ # Reference docs, procedures, plans
144
145
  ├── scripts/ # Validation, install, evaluation scripts
145
- ├── tests/ # Bats test suite (613 tests)
146
+ ├── tests/ # Bats test suite (618 tests)
146
147
  └── CHANGELOG.md
147
148
  ```
148
149
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ai-toolkit",
3
3
  "description": "Professional-grade Claude Code toolkit with persona presets, skill security auditor, expanded lifecycle hooks, experimental opt-in plugin packs, benchmark harvesting, and multi-tool support.",
4
- "version": "2.4.0",
4
+ "version": "2.4.1",
5
5
  "author": {
6
6
  "name": "SoftSpark",
7
7
  "url": "https://github.com/softspark"
@@ -301,8 +301,8 @@ Lead Session (You)
301
301
 
302
302
  ## Extension Points
303
303
 
304
- ### MCP Templates (25)
305
- `app/plugins/mcp-templates/` ships 26 ready-to-use MCP server config templates (filesystems, databases, GitHub, Slack, etc.). Opt-in via `ai-toolkit install --modules mcp-templates` or activated automatically with `--profile strict|full`.
304
+ ### MCP Templates (26)
305
+ `app/mcp-templates/` ships 26 ready-to-use MCP server config templates (filesystems, databases, GitHub, Slack, etc.). Opt-in via `ai-toolkit install --modules mcp-templates` or activated automatically with `--profile strict|full`.
306
306
 
307
307
  ### Language Rules (68 files, 13 languages)
308
308
  `app/rules/` contains per-language coding rules. Supported languages: TypeScript, Python, Go, Rust, Java, Kotlin, Swift, Dart, C#, PHP, C++, Ruby, and common (shared). Auto-detected from project files via `--auto-detect` or selected with `--modules rules-<lang>`.
package/bin/ai-toolkit.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
- const { execFileSync, spawnSync, execSync } = require('child_process');
4
+ const { execFileSync, spawnSync } = require('child_process');
5
5
  const path = require('path');
6
6
  const fs = require('fs');
7
7
 
@@ -151,6 +151,23 @@ function run(script, args = [], opts = {}) {
151
151
  }
152
152
  }
153
153
 
154
+ /**
155
+ * Propagate changes to globally installed editors (from state.json).
156
+ * Silently skips if no global editors are configured.
157
+ * @param {...string} flags - Flags to pass: --rules, --hooks, --mcp
158
+ */
159
+ function propagateGlobal(...flags) {
160
+ const result = spawnSync('python3', [scriptPath('propagate_global.py'), ...flags], {
161
+ stdio: 'inherit',
162
+ cwd: CWD,
163
+ env: { ...process.env },
164
+ });
165
+ // Non-fatal — propagation failure shouldn't block the primary operation
166
+ if (result.status !== 0) {
167
+ console.error('Warning: global editor propagation had issues (non-fatal)');
168
+ }
169
+ }
170
+
154
171
  /**
155
172
  * Generic dispatcher for SCRIPT_COMMANDS entries.
156
173
  * Resolves the script path and selects the correct cwd.
@@ -331,6 +348,7 @@ function handleRemoveRule(args) {
331
348
  }
332
349
  const targetDir = args[1] || process.env.HOME;
333
350
  run(scriptPath('remove_rule.py'), [ruleName, targetDir]);
351
+ propagateGlobal('--rules');
334
352
  }
335
353
 
336
354
  /**
@@ -345,9 +363,14 @@ function handleAddRule(args) {
345
363
  }
346
364
  // Pass URLs through directly (don't resolve as filesystem path)
347
365
  const isUrl = ruleFile.startsWith('https://') || ruleFile.startsWith('http://');
366
+ if (ruleFile.startsWith('http://')) {
367
+ console.error('Error: only HTTPS URLs are supported. Use https:// for security.');
368
+ process.exit(1);
369
+ }
348
370
  const absRuleFile = isUrl ? ruleFile : path.resolve(CWD, ruleFile);
349
371
  const ruleName = args[1];
350
372
  run(scriptPath('add_rule.py'), ruleName ? [absRuleFile, ruleName] : [absRuleFile]);
373
+ propagateGlobal('--rules');
351
374
  }
352
375
 
353
376
  /**
@@ -392,6 +415,10 @@ function handleMcp(args) {
392
415
  process.exit(1);
393
416
  }
394
417
  run(scriptPath('mcp_manager.py'), args);
418
+ // After `mcp add`, propagate to global editors
419
+ if (args[0] === 'add') {
420
+ propagateGlobal('--mcp');
421
+ }
395
422
  }
396
423
 
397
424
  /**
@@ -5,7 +5,7 @@ service: ai-toolkit
5
5
  tags: [architecture, overview, design, structure]
6
6
  version: "1.4.4"
7
7
  created: "2026-03-23"
8
- last_updated: "2026-04-13"
8
+ last_updated: "2026-04-15"
9
9
  description: "Architecture of ai-toolkit: directory layout, global install model, editor-aware MCP install, Codex translation layer, skill tiers, and integration with projects."
10
10
  ---
11
11
 
@@ -335,7 +335,7 @@ Severity levels: HIGH (blocks deployment), WARN (should fix), INFO (best practic
335
335
  ## Extension Points
336
336
 
337
337
  ### MCP Templates
338
- `app/plugins/mcp-templates/` contains 26 ready-to-use MCP server config templates. Opt-in via `ai-toolkit install --modules mcp-templates` or activated automatically with `--profile strict|full`.
338
+ `app/mcp-templates/` contains 26 ready-to-use MCP server config templates. Opt-in via `ai-toolkit install --modules mcp-templates` or activated automatically with `--profile strict|full`.
339
339
 
340
340
  ### Language Rules
341
341
  `app/rules/` provides language-specific rule files covering 13 languages (TypeScript, Python, Go, Rust, Java, Kotlin, Swift, Dart, C#, PHP, C++, Ruby, common). Auto-detected from project files via `--auto-detect` or selectable with `--modules rules-<lang>`. See README.md for current count.
@@ -4,7 +4,7 @@ category: reference
4
4
  service: ai-toolkit
5
5
  tags: [cli, commands, reference, install, update, plugin, mcp]
6
6
  created: "2026-04-13"
7
- last_updated: "2026-04-13"
7
+ last_updated: "2026-04-15"
8
8
  description: "Complete CLI reference for all ai-toolkit commands, options, and flags."
9
9
  ---
10
10
 
@@ -107,6 +107,10 @@ This means Claude-only events such as `TaskCompleted`, `TeammateIdle`,
107
107
  `SubagentStart`, `SubagentStop`, `PreCompact`, `SessionEnd`, and
108
108
  `Notification` are not available in `.codex/hooks.json`.
109
109
 
110
+ `inject-hook` automatically propagates Codex-compatible events to
111
+ `~/.codex/hooks.json` (global layer). Non-Codex events are silently skipped.
112
+ `remove-hook` cleans both Claude and Codex targets.
113
+
110
114
  ## Behavioral Limits
111
115
 
112
116
  Codex wrappers preserve workflow intent, but not every Claude runtime behavior
@@ -103,6 +103,8 @@ npx @softspark/ai-toolkit inject-hook https://example.com/hooks.json my-tool-hoo
103
103
 
104
104
  **Safety:** Entries tagged `"_source": "ai-toolkit"` are never modified or removed by this command. External tools cannot affect the toolkit's own hooks. Only HTTPS URLs are accepted.
105
105
 
106
+ **Codex propagation:** Codex-compatible events (`SessionStart`, `PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `Stop`) are automatically propagated to `~/.codex/hooks.json`. Non-Codex events are silently skipped. No extra flags needed.
107
+
106
108
  ## remove-hook
107
109
 
108
110
  Strips all hook entries from `~/.claude/settings.json` that carry a given `_source` tag. If the hook was URL-sourced, also unregisters the URL from `sources.json` and removes the cached file.
package/llms-full.txt CHANGED
@@ -5380,6 +5380,10 @@ This means Claude-only events such as `TaskCompleted`, `TeammateIdle`,
5380
5380
  `SubagentStart`, `SubagentStop`, `PreCompact`, `SessionEnd`, and
5381
5381
  `Notification` are not available in `.codex/hooks.json`.
5382
5382
 
5383
+ `inject-hook` automatically propagates Codex-compatible events to
5384
+ `~/.codex/hooks.json` (global layer). Non-Codex events are silently skipped.
5385
+ `remove-hook` cleans both Claude and Codex targets.
5386
+
5383
5387
  ## Behavioral Limits
5384
5388
 
5385
5389
  Codex wrappers preserve workflow intent, but not every Claude runtime behavior
@@ -6634,6 +6638,8 @@ npx @softspark/ai-toolkit inject-hook https://example.com/hooks.json my-tool-hoo
6634
6638
 
6635
6639
  **Safety:** Entries tagged `"_source": "ai-toolkit"` are never modified or removed by this command. External tools cannot affect the toolkit's own hooks. Only HTTPS URLs are accepted.
6636
6640
 
6641
+ **Codex propagation:** Codex-compatible events (`SessionStart`, `PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `Stop`) are automatically propagated to `~/.codex/hooks.json`. Non-Codex events are silently skipped. No extra flags needed.
6642
+
6637
6643
  ## remove-hook
6638
6644
 
6639
6645
  Strips all hook entries from `~/.claude/settings.json` that carry a given `_source` tag. If the hook was URL-sourced, also unregisters the URL from `sources.json` and removes the cached file.
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.4.0",
2
+ "version": "2.4.1",
3
3
  "components": {
4
4
  "agents": {
5
5
  "description": "44 specialized agents (orchestrator, backend, frontend, security, devops, etc.)",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softspark/ai-toolkit",
3
- "version": "2.4.0",
3
+ "version": "2.4.1",
4
4
  "description": "Professional-grade AI coding toolkit: 92 skills, 44 agents, multi-platform support (Claude, Cursor, Windsurf, Copilot, Gemini, Cline, Roo Code, Aider, Augment, Google Antigravity, Codex CLI), machine-enforced safety constitution, persona presets, skill security auditor, expanded lifecycle hooks, 11 plugin packs, and benchmark tooling.",
5
5
  "keywords": [
6
6
  "claude",
@@ -299,14 +299,28 @@ def _find_cached_npm(cache_dir: Path) -> Path | None:
299
299
 
300
300
 
301
301
  def _extract_tarball(tarball: Path, dest: Path) -> None:
302
- """Extract npm tarball (which has a package/ prefix) to dest."""
302
+ """Extract npm tarball (which has a package/ prefix) to dest.
303
+
304
+ Validates that extracted paths stay within dest to prevent path traversal.
305
+ Rejects symlinks and absolute paths.
306
+ """
307
+ dest_resolved = dest.resolve()
303
308
  with tarfile.open(tarball, "r:gz") as tf:
304
309
  for member in tf.getmembers():
305
310
  # npm tarballs have a "package/" prefix
306
- if member.name.startswith("package/"):
307
- member.name = member.name[len("package/"):]
308
- if member.name: # skip empty (the "package/" dir itself)
309
- tf.extract(member, dest)
311
+ if not member.name.startswith("package/"):
312
+ continue
313
+ member.name = member.name[len("package/"):]
314
+ if not member.name: # skip empty (the "package/" dir itself)
315
+ continue
316
+ # Reject symlinks and absolute paths
317
+ if member.issym() or member.islnk() or member.name.startswith("/"):
318
+ continue
319
+ # Path traversal protection
320
+ target = (dest / member.name).resolve()
321
+ if not str(target).startswith(str(dest_resolved)):
322
+ continue
323
+ tf.extract(member, dest)
310
324
 
311
325
 
312
326
  def _extract_version_from_tarball(filename: str, package_name: str) -> str:
@@ -332,6 +346,10 @@ def _resolve_git(
332
346
  ) -> BaseConfig:
333
347
  """Resolve from git URL (git+https://...)."""
334
348
  url = source.removeprefix("git+")
349
+ if not url.startswith("https://"):
350
+ raise ConfigResolverError(
351
+ f"Only HTTPS git URLs are supported (got: {url.split('://')[0]}://)"
352
+ )
335
353
  cache_key = hashlib.sha256(url.encode()).hexdigest()[:16]
336
354
  cache_dir = _cache_root() / "git" / cache_key
337
355
 
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("========================")
@@ -80,6 +80,9 @@ def save_sources(hooks_dir: Path | None = None,
80
80
 
81
81
  def register_url_source(hooks_dir: Path | None, hook_name: str, url: str) -> None:
82
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}")
83
86
  hooks_dir = hooks_dir or EXTERNAL_HOOKS_DIR
84
87
  sources = load_sources(hooks_dir)
85
88
  sources[hook_name] = {
@@ -49,6 +49,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
49
49
  # Protected source tag -- this CLI must never touch ai-toolkit's own entries.
50
50
  PROTECTED_SOURCE = "ai-toolkit"
51
51
 
52
+ # Codex CLI supports only these 5 hook events.
53
+ CODEX_EVENTS = {"SessionStart", "PreToolUse", "PostToolUse", "UserPromptSubmit", "Stop"}
54
+
52
55
 
53
56
  # ---------------------------------------------------------------------------
54
57
  # JSON helpers (same style as merge-hooks.py)
@@ -179,6 +182,70 @@ def merge_hooks(new_hooks: dict, existing_hooks: dict, source: str) -> dict:
179
182
  return merged
180
183
 
181
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
+
182
249
  # ---------------------------------------------------------------------------
183
250
  # CLI actions
184
251
  # ---------------------------------------------------------------------------
@@ -213,7 +280,7 @@ def _fetch_and_cache(url: str, source: str) -> str:
213
280
  sys.exit(2)
214
281
 
215
282
  if "hooks" not in parsed:
216
- print(f"Warning: no 'hooks' key found in URL response", file=sys.stderr)
283
+ print("Warning: no 'hooks' key found in URL response", file=sys.stderr)
217
284
 
218
285
  cached_path = EXTERNAL_HOOKS_DIR / f"{source}.json"
219
286
  cached_path.write_bytes(data)
@@ -310,6 +377,9 @@ def inject(hooks_file: str, target_dir: str, source_override: str = "") -> None:
310
377
  save_json(str(settings_path), settings)
311
378
  print(f"Injected hooks from '{source}' into {settings_path}")
312
379
 
380
+ # Propagate Codex-compatible events to ~/.codex/hooks.json
381
+ _inject_codex(tagged, source, target_dir)
382
+
313
383
 
314
384
  def remove(source_name: str, target_dir: str) -> None:
315
385
  """Remove all hook entries tagged with *source_name*.
@@ -354,6 +424,9 @@ def remove(source_name: str, target_dir: str) -> None:
354
424
  save_json(str(settings_path), settings)
355
425
  print(f"Removed hooks with source '{source_name}' from {settings_path}")
356
426
 
427
+ # Remove from Codex global hooks
428
+ _remove_codex(source_name, target_dir)
429
+
357
430
  # Unregister URL source if present
358
431
  try:
359
432
  from hook_sources import unregister_source
@@ -54,7 +54,10 @@ from install_steps.install_state import (
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
@@ -708,17 +711,31 @@ def main() -> None:
708
711
  profile = cfg["profile"]
709
712
 
710
713
  lang_modules = [m for m in (resolved_modules or []) if m.startswith("rules-")]
711
- editors_arg: str = cfg["editors"]
714
+ local_editors_arg: str = cfg["editors"]
712
715
  install_local_project(rules_dir, dry_run, reset, lang_modules or None,
713
- editors=editors_arg,
716
+ editors=local_editors_arg,
714
717
  merged_config=merged_config)
718
+ installed_eds: list[str] = [] # local install doesn't track global editors
715
719
  install_strict_git_hooks(profile, local, dry_run)
716
720
  else:
717
721
  # Global install
718
722
  print_banner(target_dir, rules_dir, profile, only, skip, dry_run,
719
723
  modules=resolved_modules)
720
724
  install_claude_code(target_dir, hooks_scripts_dir, rules_dir, only, skip, dry_run)
721
- install_ai_tools(target_dir, rules_dir, only, skip, dry_run)
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)
722
739
  install_persona(target_dir, persona, dry_run)
723
740
  install_strict_git_hooks(profile, local, dry_run)
724
741
 
@@ -749,6 +766,10 @@ def main() -> None:
749
766
  extends_info=extends_info,
750
767
  )
751
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
+
752
773
  # Register project in global registry (for `ai-toolkit update` propagation)
753
774
  # Skipped when called from update_projects.py (--skip-register) to avoid
754
775
  # concurrent writes to projects.json during parallel updates.
@@ -756,10 +777,20 @@ def main() -> None:
756
777
  extends_source = ""
757
778
  if extends_info:
758
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
+
759
789
  is_new = register_project(
760
790
  project_dir,
761
791
  profile=profile or "standard",
762
792
  extends=extends_source,
793
+ editors=local_eds_for_registry,
763
794
  )
764
795
  if is_new:
765
796
  print(f" Registered project in {TOOLKIT_DATA_DIR / 'projects.json'}")
@@ -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)}")
@@ -105,7 +105,7 @@ def refresh_url_hooks(target_dir: str | None = None) -> None:
105
105
  Called during ``ai-toolkit update`` to keep URL-sourced hooks current.
106
106
  On fetch failure, warns and keeps the cached version.
107
107
  """
108
- from hook_sources import get_url_hooks
108
+ from hook_sources import get_url_hooks, register_url_source
109
109
  from paths import EXTERNAL_HOOKS_DIR
110
110
  from url_fetch import fetch_url
111
111
  import json
@@ -124,6 +124,7 @@ def refresh_url_hooks(target_dir: str | None = None) -> None:
124
124
  # Validate JSON before caching
125
125
  json.loads(data)
126
126
  cached_file.write_bytes(data)
127
+ register_url_source(None, hook_name, url)
127
128
  print(f" Refreshed: {hook_name} (from {url})")
128
129
  except Exception as exc:
129
130
  if cached_file.is_file():
@@ -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
@@ -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()
@@ -23,8 +23,6 @@ from paths import RULES_DIR
23
23
  from url_fetch import fetch_url as fetch_url # noqa: F811 — re-export
24
24
 
25
25
  _SOURCES_FILENAME = "sources.json"
26
- _FETCH_TIMEOUT = 30 # seconds
27
- _FETCH_MAX_BYTES = 10 * 1024 * 1024 # 10MB
28
26
 
29
27
 
30
28
  # ---------------------------------------------------------------------------
@@ -83,6 +81,9 @@ def save_sources(rules_dir: Path | None = None,
83
81
 
84
82
  def register_url_source(rules_dir: Path | None, rule_name: str, url: str) -> None:
85
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}")
86
87
  rules_dir = rules_dir or RULES_DIR
87
88
  sources = load_sources(rules_dir)
88
89
  sources[rule_name] = {
@@ -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,
@@ -37,6 +37,11 @@ def fetch_url(url: str) -> bytes:
37
37
  ctx = ssl.create_default_context()
38
38
  with urllib.request.urlopen(url, timeout=_FETCH_TIMEOUT, context=ctx) as resp:
39
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
+ )
40
45
 
41
46
  # Basic binary detection — reject if null bytes present
42
47
  if b"\x00" in data: