@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 +22 -0
- package/README.md +8 -7
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/ARCHITECTURE.md +2 -2
- package/bin/ai-toolkit.js +28 -1
- package/kb/reference/architecture-overview.md +2 -2
- package/kb/reference/cli-reference.md +1 -1
- package/kb/reference/codex-cli-compatibility.md +4 -0
- package/kb/reference/extension-api.md +2 -0
- package/llms-full.txt +6 -0
- 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 +3 -0
- package/scripts/inject_hook_cli.py +74 -1
- package/scripts/install.py +34 -3
- package/scripts/install_steps/ai_tools.py +78 -15
- package/scripts/install_steps/install_state.py +25 -0
- package/scripts/install_steps/markers.py +2 -1
- package/scripts/install_steps/project_registry.py +9 -0
- package/scripts/propagate_global.py +92 -0
- package/scripts/rule_sources.py +3 -2
- package/scripts/update_projects.py +7 -1
- package/scripts/url_fetch.py +5 -0
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)
|
|
7
7
|
[](app/skills/)
|
|
8
8
|
[](app/agents/)
|
|
9
|
-
[](tests/)
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
-
## What's New in v2.4.
|
|
13
|
+
## What's New in v2.4.1
|
|
14
14
|
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
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 (
|
|
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.
|
|
4
|
+
"version": "2.4.1",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "SoftSpark",
|
|
7
7
|
"url": "https://github.com/softspark"
|
package/app/ARCHITECTURE.md
CHANGED
|
@@ -301,8 +301,8 @@ Lead Session (You)
|
|
|
301
301
|
|
|
302
302
|
## Extension Points
|
|
303
303
|
|
|
304
|
-
### MCP Templates (
|
|
305
|
-
`app/
|
|
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
|
|
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-
|
|
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/
|
|
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-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softspark/ai-toolkit",
|
|
3
|
-
"version": "2.4.
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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("========================")
|
package/scripts/hook_sources.py
CHANGED
|
@@ -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(
|
|
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
|
package/scripts/install.py
CHANGED
|
@@ -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
|
-
|
|
714
|
+
local_editors_arg: str = cfg["editors"]
|
|
712
715
|
install_local_project(rules_dir, dry_run, reset, lang_modules or None,
|
|
713
|
-
editors=
|
|
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
|
-
|
|
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
|
-
|
|
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)}")
|
|
@@ -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()
|
package/scripts/rule_sources.py
CHANGED
|
@@ -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
|
-
|
|
36
|
+
cmd_args,
|
|
31
37
|
cwd=project_path,
|
|
32
38
|
capture_output=True,
|
|
33
39
|
text=True,
|
package/scripts/url_fetch.py
CHANGED
|
@@ -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:
|