@softspark/ai-toolkit 2.3.0 → 2.4.0

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,25 @@ Versioning follows [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
+ ## v2.4.0 — URL Hook Injection & Karpathy Coding Rules (2026-04-15)
11
+
12
+ ### Added
13
+ - **URL hook injection** — `ai-toolkit inject-hook https://...` fetches, caches, and injects hooks from HTTPS URLs. Cached in `~/.softspark/ai-toolkit/hooks/external/`, auto-refreshed on every `update`. `remove-hook` also unregisters URL source and cleans cache.
14
+ - **Shared URL fetch module** — extracted `url_fetch.py` from `rule_sources.py` for reuse by both rule and hook URL sources.
15
+ - **Hook URL source registry** — `hook_sources.py` tracks URL-sourced hooks in `sources.json` (analogous to `rule_sources.py`).
16
+ - **Surgical Changes rule** — orphan cleanup protocol, match existing style, don't touch adjacent code (inspired by Karpathy's LLM coding guidelines).
17
+ - **Goal-Driven Execution rule** — `step → verify: check` pattern for multi-step tasks, strong success criteria before looping.
18
+
19
+ ---
20
+
21
+ ## v2.3.1 — Release Quality Gate (2026-04-14)
22
+
23
+ ### Fixed
24
+ - **README "What's New" section** — was stuck at v2.1.3, now auto-validated by `validate.py --strict`
25
+ - **Release SOP** — added mandatory "Update README What's New" step to Phase 3 checklist
26
+
27
+ ---
28
+
10
29
  ## v2.3.0 — Jira MCP Template & Cross-Editor Sync (2026-04-14)
11
30
 
12
31
  ### Added
package/README.md CHANGED
@@ -6,16 +6,16 @@
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-606%20passing-success)](tests/)
9
+ [![Tests](https://img.shields.io/badge/tests-613%20passing-success)](tests/)
10
10
 
11
11
  ---
12
12
 
13
- ## What's New in v2.1.3
13
+ ## What's New in v2.4.0
14
14
 
15
- - **Idempotent update** — `ai-toolkit update` no longer dirties git with extra blank lines in generated files
16
- - **Custom rules in generators** — `generate:all` preserves registered rules from other repos across all platforms
17
- - **README restructured** — 951 292 lines with TOC, links to KB docs
18
- - **3 new KB docs** — CLI Reference, Unique Features, Ecosystem Comparison
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
19
19
 
20
20
  See [CHANGELOG.md](CHANGELOG.md) for full history.
21
21
 
@@ -142,7 +142,7 @@ ai-toolkit/
142
142
  │ └── ARCHITECTURE.md # Full system design
143
143
  ├── kb/ # Reference docs, procedures, plans
144
144
  ├── scripts/ # Validation, install, evaluation scripts
145
- ├── tests/ # Bats test suite (606 tests)
145
+ ├── tests/ # Bats test suite (613 tests)
146
146
  └── CHANGELOG.md
147
147
  ```
148
148
 
@@ -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.3.0",
4
+ "version": "2.4.0",
5
5
  "author": {
6
6
  "name": "SoftSpark",
7
7
  "url": "https://github.com/softspark"
@@ -309,8 +309,8 @@ Lead Session (You)
309
309
 
310
310
  Language rules are propagated to **all configured editors** — not just Claude. `dir_rules_shared.build_language_rules()` reads `app/rules/<lang>/*.md`, strips frontmatter, and returns combined content per language. Each directory-based generator (Cursor, Windsurf, Cline, Roo, Augment, Antigravity, Codex) emits `ai-toolkit-lang-<lang>` files in its native format. Registered custom rules (`~/.softspark/ai-toolkit/rules/`) are similarly propagated as `ai-toolkit-custom-<name>` files via `build_registered_rules()`.
311
311
 
312
- ### Extension API (`inject-hook`)
313
- The `inject_section_cli.py` script provides a stable marker-based injection API. Any tool can add sections to `CLAUDE.md`, `constitution.md`, or `ARCHITECTURE.md` without overwriting user content, using `<!-- TOOLKIT:START:<id> -->` / `<!-- TOOLKIT:END:<id> -->` markers.
312
+ ### Extension API (`inject-hook`, `inject-rule`)
313
+ The `inject_section_cli.py` script provides a stable marker-based injection API. Any tool can add sections to `CLAUDE.md`, `constitution.md`, or `ARCHITECTURE.md` without overwriting user content, using `<!-- TOOLKIT:START:<id> -->` / `<!-- TOOLKIT:END:<id> -->` markers. `inject_hook_cli.py` injects hooks into `settings.json` with `_source` tags — supports both local files and HTTPS URLs (cached in `~/.softspark/ai-toolkit/hooks/external/`, auto-refreshed on `update`). Shared URL fetch logic lives in `url_fetch.py`.
314
314
 
315
315
  ### SLM Compilation (`compile-slm`)
316
316
  `scripts/compile_slm.py` compiles the full toolkit (20K+ tokens) into a minimal system prompt for Small Language Models (2K-16K tokens). Pipeline: Parse → Score → Compress → Pack → Emit. Supports 4 compression levels (ultra-light, light, standard, extended), 4 output formats (raw, ollama, json-string, aider), persona-aware scoring, and language-aware rule filtering. Profile `offline-slm` in `manifest.json`. Constitution is always included (non-negotiable).
@@ -7,11 +7,12 @@ version: "1.0.0"
7
7
  # Universal Coding Style
8
8
 
9
9
  ## Principles
10
- - KISS: simplest solution that works. Clever code is a liability.
10
+ - KISS: simplest solution that works. Clever code is a liability. If 200 lines could be 50, rewrite.
11
11
  - DRY: extract when you repeat 3+ times, not before.
12
- - YAGNI: do not build features "just in case."
12
+ - YAGNI: do not build features "just in case." No abstractions for single-use code.
13
13
  - Prefer immutability: use `const`, `final`, `val`, `let` by default.
14
14
  - Fail fast: validate inputs at boundaries, return early on errors.
15
+ - State assumptions before coding. If uncertain or multiple interpretations exist, ask — don't pick silently.
15
16
 
16
17
  ## Naming
17
18
  - Use descriptive names that reveal intent (`remainingRetries`, not `r`).
@@ -45,6 +46,19 @@ version: "1.0.0"
45
46
  - Max line length: 80-120 characters depending on language convention.
46
47
  - Trailing commas in multi-line structures (where language supports).
47
48
 
49
+ ## Surgical Changes
50
+ - Touch only what the task requires. Every changed line should trace to the request.
51
+ - Match existing style, even if you would do it differently.
52
+ - Do not "improve" adjacent code, comments, or formatting unprompted.
53
+ - Orphan cleanup: remove imports/variables/functions that YOUR changes made unused.
54
+ - Do not remove pre-existing dead code unless explicitly asked.
55
+
56
+ ## Goal-Driven Execution
57
+ - Transform vague tasks into verifiable goals before starting.
58
+ - For multi-step work, state a brief plan with verification per step:
59
+ `1. [Step] → verify: [check]`
60
+ - Strong success criteria enable independent looping. Weak criteria ("make it work") require clarification — ask first.
61
+
48
62
  ## Anti-Patterns to Avoid
49
63
  - God classes/modules with 500+ lines and multiple responsibilities.
50
64
  - Deep nesting (>3 levels): use early returns and extract functions.
package/bin/ai-toolkit.js CHANGED
@@ -65,8 +65,8 @@ const COMMANDS = {
65
65
  uninstall: 'Remove ai-toolkit from ~/.claude/',
66
66
  'add-rule': 'Register a rule file or URL in ~/.softspark/ai-toolkit/rules/ (URL rules auto-refresh on update)',
67
67
  'remove-rule': 'Unregister a rule from ~/.softspark/ai-toolkit/rules/ and remove its block from CLAUDE.md',
68
- 'inject-hook': 'Inject external hooks into ~/.claude/settings.json (tagged with _source for idempotent updates)',
69
- 'remove-hook': 'Remove injected hooks by source name from ~/.claude/settings.json',
68
+ 'inject-hook': 'Inject external hooks (file or URL) into ~/.claude/settings.json (URL hooks auto-refresh on update)',
69
+ 'remove-hook': 'Remove injected hooks by source name from ~/.claude/settings.json (also unregisters URL source)',
70
70
  validate: 'Verify toolkit integrity',
71
71
  doctor: 'Check install health, hooks, and artifact drift',
72
72
  eject: 'Export standalone config (no symlinks, no toolkit dependency)',
@@ -226,10 +226,11 @@ function showHelp() {
226
226
  console.log(' <rule-name> Name of rule to unregister (filename without .md)');
227
227
  console.log(' [target-dir] Target dir containing .claude/CLAUDE.md (default: $HOME)');
228
228
  console.log('\nOptions for inject-hook:');
229
- console.log(' <hooks-file> Path to JSON file with {"hooks": {"EventName": [...]}} format');
230
- console.log(' [target-dir] Target dir containing .claude/settings.json (default: $HOME)');
229
+ console.log(' <hooks-file-or-url> Path to JSON file or HTTPS URL with {"hooks": {"EventName": [...]}}');
230
+ console.log(' [hook-name] Override source name (default: filename/URL stem)');
231
+ console.log(' [target-dir] Target dir containing .claude/settings.json (default: $HOME)');
231
232
  console.log('\nOptions for remove-hook:');
232
- console.log(' <source-name> Source tag to remove (derived from hooks filename stem)');
233
+ console.log(' <source-name> Source tag to remove (also unregisters URL source if present)');
233
234
  console.log(' [target-dir] Target dir containing .claude/settings.json (default: $HOME)');
234
235
  console.log('\nOptions for add-rule:');
235
236
  console.log(' <rule-file> Path to .md rule file or HTTPS URL to register globally');
@@ -350,18 +351,21 @@ function handleAddRule(args) {
350
351
  }
351
352
 
352
353
  /**
353
- * Handle `ai-toolkit inject-hook` -- injects external hooks into settings.json.
354
+ * Handle `ai-toolkit inject-hook` -- injects external hooks (file or URL) into settings.json.
354
355
  * @param {string[]} args
355
356
  */
356
357
  function handleInjectHook(args) {
357
- const hooksFile = args[0];
358
- if (!hooksFile) {
359
- console.error('Usage: ai-toolkit inject-hook <hooks-file.json> [target-dir]');
358
+ const source = args[0];
359
+ if (!source) {
360
+ console.error('Usage: ai-toolkit inject-hook <hooks-file-or-url> [hook-name] [target-dir]');
360
361
  process.exit(1);
361
362
  }
362
- const absHooksFile = path.resolve(CWD, hooksFile);
363
- const targetDir = args[1] || process.env.HOME;
364
- run(scriptPath('inject_hook_cli.py'), [absHooksFile, targetDir]);
363
+ const isUrl = source.startsWith('https://') || source.startsWith('http://');
364
+ // For URLs pass as-is; for files resolve to absolute path
365
+ const resolvedSource = isUrl ? source : path.resolve(CWD, source);
366
+ // Pass remaining args through — Python CLI handles positional parsing
367
+ const remaining = args.slice(1);
368
+ run(scriptPath('inject_hook_cli.py'), [resolvedSource, ...remaining]);
365
369
  }
366
370
 
367
371
  /**
@@ -157,6 +157,18 @@ Add entry at the top of `CHANGELOG.md` (after the header, before previous releas
157
157
  - Date format: `YYYY-MM-DD`
158
158
  - Title: short, descriptive, no version number repetition
159
159
 
160
+ ### Update README "What's New" section
161
+
162
+ **MANDATORY on every release.** Update the `## What's New in vX.Y.Z` section in `README.md`:
163
+
164
+ 1. Change the heading version: `## What's New in vX.Y.Z`
165
+ 2. Replace bullet points with 3-5 highlights from this release
166
+ 3. Keep the `See [CHANGELOG.md](CHANGELOG.md) for full history.` link
167
+
168
+ > **Warning:** This section is the first thing users see after the badges.
169
+ > A stale version here (e.g., "What's New in v2.1.3" when shipping v2.3.0)
170
+ > signals an unmaintained project. Do NOT skip this step.
171
+
160
172
  ---
161
173
 
162
174
  ## Phase 4: Regenerate Artifacts
@@ -340,8 +340,8 @@ Severity levels: HIGH (blocks deployment), WARN (should fix), INFO (best practic
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.
342
342
 
343
- ### Extension API (`inject-hook`)
344
- `inject_section_cli.py` provides a stable marker-based API for injecting content into `CLAUDE.md`, `constitution.md`, or `ARCHITECTURE.md` without overwriting user content.
343
+ ### Extension API (`inject-hook`, `inject-rule`)
344
+ `inject_section_cli.py` provides a stable marker-based API for injecting content into `CLAUDE.md`, `constitution.md`, or `ARCHITECTURE.md` without overwriting user content. `inject_hook_cli.py` injects hooks into `settings.json` with `_source` tags — supports both local files and HTTPS URLs (cached in `~/.softspark/ai-toolkit/hooks/external/`, auto-refreshed on `update`).
345
345
 
346
346
  ### Manifest Install (`--modules`, `--auto-detect`)
347
347
  `manifest.json` defines all installable components as named modules. Install individual modules with `ai-toolkit install --modules <name>` or enable auto-detection to select language rules based on files found in the project.
@@ -34,10 +34,10 @@ Usage: ai-toolkit <command> [options]
34
34
 
35
35
  | Command | Description |
36
36
  |---------|-------------|
37
- | `add-rule <rule.md> [name]` | Register rule in `~/.softspark/ai-toolkit/rules/` — auto-applied on every `update` |
37
+ | `add-rule <rule.md\|url> [name]` | Register rule in `~/.softspark/ai-toolkit/rules/` — auto-applied on every `update` |
38
38
  | `remove-rule <name> [dir]` | Unregister rule and remove its block from `CLAUDE.md` |
39
- | `inject-hook <file.json>` | Inject external hooks into settings.json (idempotent, `_source` tagged) |
40
- | `remove-hook <name>` | Remove injected hooks by source name |
39
+ | `inject-hook <file.json\|url> [name]` | Inject external hooks (file or URL) into settings.json (idempotent, `_source` tagged, URL hooks auto-refresh on update) |
40
+ | `remove-hook <name>` | Remove injected hooks by source name (also unregisters URL source if present) |
41
41
 
42
42
  ## MCP Management
43
43
 
@@ -3,9 +3,9 @@ title: "Extension API Reference"
3
3
  category: reference
4
4
  service: ai-toolkit
5
5
  tags: [extension-api, inject-rule, inject-hook, mcp-templates, integration, editors]
6
- version: "1.3.9"
6
+ version: "1.4.0"
7
7
  created: "2026-04-07"
8
- last_updated: "2026-04-12"
8
+ last_updated: "2026-04-15"
9
9
  description: "Reference for ai-toolkit's extension API: inject-rule, inject-hook, remove-rule, remove-hook, and editor-aware MCP template management."
10
10
  ---
11
11
 
@@ -23,9 +23,9 @@ This design is intentional: ai-toolkit is a generic toolkit. Consumers (MCP serv
23
23
  |---------|-------------|-----------|------------|
24
24
  | `inject-rule <file.md>` | `~/.claude/CLAUDE.md` | HTML comment markers (`<!-- TOOLKIT:name -->`) | Yes |
25
25
  | `remove-rule <name>` | `~/.claude/CLAUDE.md` | Strip markers by block name | Yes |
26
- | `inject-hook <file.json>` | `~/.claude/settings.json` | JSON `_source` tag per entry | Yes |
27
- | `remove-hook <name>` | `~/.claude/settings.json` | Strip all entries with matching `_source` | Yes |
28
- | `add-rule <file.md>` | `~/.softspark/ai-toolkit/rules/` | File copy + re-inject all rules on next `update` | Yes |
26
+ | `inject-hook <file.json\|url> [name]` | `~/.claude/settings.json` | JSON `_source` tag per entry, URL cached + registered | Yes |
27
+ | `remove-hook <name>` | `~/.claude/settings.json` | Strip all entries with matching `_source`, unregister URL source | Yes |
28
+ | `add-rule <file.md\|url>` | `~/.softspark/ai-toolkit/rules/` | File copy + re-inject all rules on next `update` | Yes |
29
29
  | `mcp add <name...>` | `.mcp.json` | Merge `mcpServers` block from template | Yes |
30
30
  | `mcp install --editor <name...>` | Native editor MCP config | Render canonical template into editor format | Yes |
31
31
 
@@ -60,13 +60,20 @@ The argument is the block name (file stem used during `inject-rule`). If the blo
60
60
 
61
61
  ## inject-hook
62
62
 
63
- Injects hook entries from a JSON file into `~/.claude/settings.json`. Every injected entry is tagged with `"_source": "<source-name>"` where the source name is derived from the filename stem.
63
+ Injects hook entries from a JSON file or HTTPS URL into `~/.claude/settings.json`. Every injected entry is tagged with `"_source": "<source-name>"` where the source name is derived from the filename stem or URL last segment.
64
64
 
65
65
  ```bash
66
+ # From local file
66
67
  npx @softspark/ai-toolkit inject-hook ./my-tool-hooks.json
68
+
69
+ # From URL (HTTPS only) — cached locally, auto-refreshed on update
70
+ npx @softspark/ai-toolkit inject-hook https://example.com/my-tool-hooks.json
71
+
72
+ # With explicit source name
73
+ npx @softspark/ai-toolkit inject-hook https://example.com/hooks.json my-tool-hooks
67
74
  ```
68
75
 
69
- **Implementation:** `scripts/inject_hook_cli.py`.
76
+ **Implementation:** `scripts/inject_hook_cli.py`, `scripts/hook_sources.py`, `scripts/url_fetch.py`.
70
77
 
71
78
  **Input format:**
72
79
  ```json
@@ -88,15 +95,17 @@ npx @softspark/ai-toolkit inject-hook ./my-tool-hooks.json
88
95
  }
89
96
  ```
90
97
 
91
- **Source name derivation:** `my-tool-hooks.json` → source name `"my-tool-hooks"`. All entries are tagged `"_source": "my-tool-hooks"` in settings.json.
98
+ **Source name derivation:** `my-tool-hooks.json` → source name `"my-tool-hooks"`. For URLs: `https://example.com/path/my-tool-hooks.json` → `"my-tool-hooks"`. All entries are tagged `"_source": "my-tool-hooks"` in settings.json.
99
+
100
+ **URL support:** When an HTTPS URL is provided, the JSON is fetched, validated, cached in `~/.softspark/ai-toolkit/hooks/external/<name>.json`, and registered in `sources.json`. On every `ai-toolkit update`, URL-sourced hooks are re-fetched and re-injected automatically. If the fetch fails during update, the cached version is used.
92
101
 
93
102
  **Idempotency:** Re-running strips all existing entries with the same source name, then appends the new ones. No duplicates accumulate.
94
103
 
95
- **Safety:** Entries tagged `"_source": "ai-toolkit"` are never modified or removed by this command. External tools cannot affect the toolkit's own hooks.
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.
96
105
 
97
106
  ## remove-hook
98
107
 
99
- Strips all hook entries from `~/.claude/settings.json` that carry a given `_source` tag.
108
+ 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.
100
109
 
101
110
  ```bash
102
111
  npx @softspark/ai-toolkit remove-hook my-tool-hooks
@@ -140,14 +149,14 @@ When `install` runs with `--scope project`, ai-toolkit also updates `.mcp.json`
140
149
  │ Public Extension API: │
141
150
  │ inject-rule <file.md> → CLAUDE.md │
142
151
  │ remove-rule <name> → CLAUDE.md │
143
- │ inject-hook <file.json> → settings.json │
152
+ │ inject-hook <file|url> → settings.json │
144
153
  │ remove-hook <name> → settings.json │
145
- │ add-rule <file.md> → rules/ registry │
154
+ │ add-rule <file|url> → rules/ registry │
146
155
  │ mcp add <template> → .mcp.json │
147
156
  │ mcp install <template> → editor-native MCP │
148
157
  │ │
149
158
  │ Idempotent: markers (rules) / _source tags (hooks) │
150
- ai-toolkit NEVER calls external services
159
+ URL sources: cached + auto-refreshed on update
151
160
  └──────────────────────────────────────────────────────┘
152
161
 
153
162
  │ uses API
package/llms-full.txt CHANGED
@@ -3669,6 +3669,18 @@ Add entry at the top of `CHANGELOG.md` (after the header, before previous releas
3669
3669
  - Date format: `YYYY-MM-DD`
3670
3670
  - Title: short, descriptive, no version number repetition
3671
3671
 
3672
+ ### Update README "What's New" section
3673
+
3674
+ **MANDATORY on every release.** Update the `## What's New in vX.Y.Z` section in `README.md`:
3675
+
3676
+ 1. Change the heading version: `## What's New in vX.Y.Z`
3677
+ 2. Replace bullet points with 3-5 highlights from this release
3678
+ 3. Keep the `See [CHANGELOG.md](CHANGELOG.md) for full history.` link
3679
+
3680
+ > **Warning:** This section is the first thing users see after the badges.
3681
+ > A stale version here (e.g., "What's New in v2.1.3" when shipping v2.3.0)
3682
+ > signals an unmaintained project. Do NOT skip this step.
3683
+
3672
3684
  ---
3673
3685
 
3674
3686
  ## Phase 4: Regenerate Artifacts
@@ -4779,8 +4791,8 @@ Severity levels: HIGH (blocks deployment), WARN (should fix), INFO (best practic
4779
4791
  ### Language Rules
4780
4792
  `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.
4781
4793
 
4782
- ### Extension API (`inject-hook`)
4783
- `inject_section_cli.py` provides a stable marker-based API for injecting content into `CLAUDE.md`, `constitution.md`, or `ARCHITECTURE.md` without overwriting user content.
4794
+ ### Extension API (`inject-hook`, `inject-rule`)
4795
+ `inject_section_cli.py` provides a stable marker-based API for injecting content into `CLAUDE.md`, `constitution.md`, or `ARCHITECTURE.md` without overwriting user content. `inject_hook_cli.py` injects hooks into `settings.json` with `_source` tags — supports both local files and HTTPS URLs (cached in `~/.softspark/ai-toolkit/hooks/external/`, auto-refreshed on `update`).
4784
4796
 
4785
4797
  ### Manifest Install (`--modules`, `--auto-detect`)
4786
4798
  `manifest.json` defines all installable components as named modules. Install individual modules with `ai-toolkit install --modules <name>` or enable auto-detection to select language rules based on files found in the project.
@@ -5155,10 +5167,10 @@ Usage: ai-toolkit <command> [options]
5155
5167
 
5156
5168
  | Command | Description |
5157
5169
  |---------|-------------|
5158
- | `add-rule <rule.md> [name]` | Register rule in `~/.softspark/ai-toolkit/rules/` — auto-applied on every `update` |
5170
+ | `add-rule <rule.md\|url> [name]` | Register rule in `~/.softspark/ai-toolkit/rules/` — auto-applied on every `update` |
5159
5171
  | `remove-rule <name> [dir]` | Unregister rule and remove its block from `CLAUDE.md` |
5160
- | `inject-hook <file.json>` | Inject external hooks into settings.json (idempotent, `_source` tagged) |
5161
- | `remove-hook <name>` | Remove injected hooks by source name |
5172
+ | `inject-hook <file.json\|url> [name]` | Inject external hooks (file or URL) into settings.json (idempotent, `_source` tagged, URL hooks auto-refresh on update) |
5173
+ | `remove-hook <name>` | Remove injected hooks by source name (also unregisters URL source if present) |
5162
5174
 
5163
5175
  ## MCP Management
5164
5176
 
@@ -6522,9 +6534,9 @@ title: "Extension API Reference"
6522
6534
  category: reference
6523
6535
  service: ai-toolkit
6524
6536
  tags: [extension-api, inject-rule, inject-hook, mcp-templates, integration, editors]
6525
- version: "1.3.9"
6537
+ version: "1.4.0"
6526
6538
  created: "2026-04-07"
6527
- last_updated: "2026-04-12"
6539
+ last_updated: "2026-04-15"
6528
6540
  description: "Reference for ai-toolkit's extension API: inject-rule, inject-hook, remove-rule, remove-hook, and editor-aware MCP template management."
6529
6541
  ---
6530
6542
 
@@ -6542,9 +6554,9 @@ This design is intentional: ai-toolkit is a generic toolkit. Consumers (MCP serv
6542
6554
  |---------|-------------|-----------|------------|
6543
6555
  | `inject-rule <file.md>` | `~/.claude/CLAUDE.md` | HTML comment markers (`<!-- TOOLKIT:name -->`) | Yes |
6544
6556
  | `remove-rule <name>` | `~/.claude/CLAUDE.md` | Strip markers by block name | Yes |
6545
- | `inject-hook <file.json>` | `~/.claude/settings.json` | JSON `_source` tag per entry | Yes |
6546
- | `remove-hook <name>` | `~/.claude/settings.json` | Strip all entries with matching `_source` | Yes |
6547
- | `add-rule <file.md>` | `~/.softspark/ai-toolkit/rules/` | File copy + re-inject all rules on next `update` | Yes |
6557
+ | `inject-hook <file.json\|url> [name]` | `~/.claude/settings.json` | JSON `_source` tag per entry, URL cached + registered | Yes |
6558
+ | `remove-hook <name>` | `~/.claude/settings.json` | Strip all entries with matching `_source`, unregister URL source | Yes |
6559
+ | `add-rule <file.md\|url>` | `~/.softspark/ai-toolkit/rules/` | File copy + re-inject all rules on next `update` | Yes |
6548
6560
  | `mcp add <name...>` | `.mcp.json` | Merge `mcpServers` block from template | Yes |
6549
6561
  | `mcp install --editor <name...>` | Native editor MCP config | Render canonical template into editor format | Yes |
6550
6562
 
@@ -6579,13 +6591,20 @@ The argument is the block name (file stem used during `inject-rule`). If the blo
6579
6591
 
6580
6592
  ## inject-hook
6581
6593
 
6582
- Injects hook entries from a JSON file into `~/.claude/settings.json`. Every injected entry is tagged with `"_source": "<source-name>"` where the source name is derived from the filename stem.
6594
+ Injects hook entries from a JSON file or HTTPS URL into `~/.claude/settings.json`. Every injected entry is tagged with `"_source": "<source-name>"` where the source name is derived from the filename stem or URL last segment.
6583
6595
 
6584
6596
  ```bash
6597
+ # From local file
6585
6598
  npx @softspark/ai-toolkit inject-hook ./my-tool-hooks.json
6599
+
6600
+ # From URL (HTTPS only) — cached locally, auto-refreshed on update
6601
+ npx @softspark/ai-toolkit inject-hook https://example.com/my-tool-hooks.json
6602
+
6603
+ # With explicit source name
6604
+ npx @softspark/ai-toolkit inject-hook https://example.com/hooks.json my-tool-hooks
6586
6605
  ```
6587
6606
 
6588
- **Implementation:** `scripts/inject_hook_cli.py`.
6607
+ **Implementation:** `scripts/inject_hook_cli.py`, `scripts/hook_sources.py`, `scripts/url_fetch.py`.
6589
6608
 
6590
6609
  **Input format:**
6591
6610
  ```json
@@ -6607,15 +6626,17 @@ npx @softspark/ai-toolkit inject-hook ./my-tool-hooks.json
6607
6626
  }
6608
6627
  ```
6609
6628
 
6610
- **Source name derivation:** `my-tool-hooks.json` → source name `"my-tool-hooks"`. All entries are tagged `"_source": "my-tool-hooks"` in settings.json.
6629
+ **Source name derivation:** `my-tool-hooks.json` → source name `"my-tool-hooks"`. For URLs: `https://example.com/path/my-tool-hooks.json` → `"my-tool-hooks"`. All entries are tagged `"_source": "my-tool-hooks"` in settings.json.
6630
+
6631
+ **URL support:** When an HTTPS URL is provided, the JSON is fetched, validated, cached in `~/.softspark/ai-toolkit/hooks/external/<name>.json`, and registered in `sources.json`. On every `ai-toolkit update`, URL-sourced hooks are re-fetched and re-injected automatically. If the fetch fails during update, the cached version is used.
6611
6632
 
6612
6633
  **Idempotency:** Re-running strips all existing entries with the same source name, then appends the new ones. No duplicates accumulate.
6613
6634
 
6614
- **Safety:** Entries tagged `"_source": "ai-toolkit"` are never modified or removed by this command. External tools cannot affect the toolkit's own hooks.
6635
+ **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.
6615
6636
 
6616
6637
  ## remove-hook
6617
6638
 
6618
- Strips all hook entries from `~/.claude/settings.json` that carry a given `_source` tag.
6639
+ 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.
6619
6640
 
6620
6641
  ```bash
6621
6642
  npx @softspark/ai-toolkit remove-hook my-tool-hooks
@@ -6659,14 +6680,14 @@ When `install` runs with `--scope project`, ai-toolkit also updates `.mcp.json`
6659
6680
  │ Public Extension API: │
6660
6681
  │ inject-rule <file.md> → CLAUDE.md │
6661
6682
  │ remove-rule <name> → CLAUDE.md │
6662
- │ inject-hook <file.json> → settings.json │
6683
+ │ inject-hook <file|url> → settings.json │
6663
6684
  │ remove-hook <name> → settings.json │
6664
- │ add-rule <file.md> → rules/ registry │
6685
+ │ add-rule <file|url> → rules/ registry │
6665
6686
  │ mcp add <template> → .mcp.json │
6666
6687
  │ mcp install <template> → editor-native MCP │
6667
6688
  │ │
6668
6689
  │ Idempotent: markers (rules) / _source tags (hooks) │
6669
- ai-toolkit NEVER calls external services
6690
+ URL sources: cached + auto-refreshed on update
6670
6691
  └──────────────────────────────────────────────────────┘
6671
6692
 
6672
6693
  │ uses API
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.3.0",
2
+ "version": "2.4.0",
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.3.0",
3
+ "version": "2.4.0",
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",
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env python3
2
+ """URL source registry for remotely-sourced hooks.
3
+
4
+ Tracks which hooks were registered from a URL so that `ai-toolkit update`
5
+ can re-fetch the latest version before injection.
6
+
7
+ Metadata stored in ~/.softspark/ai-toolkit/hooks/external/sources.json.
8
+
9
+ Stdlib-only — no external dependencies.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import sys
16
+ import tempfile
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
22
+ from paths import EXTERNAL_HOOKS_DIR
23
+
24
+ _SOURCES_FILENAME = "sources.json"
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Load / Save
29
+ # ---------------------------------------------------------------------------
30
+
31
+ def _sources_path(hooks_dir: Path | None = None) -> Path:
32
+ return (hooks_dir or EXTERNAL_HOOKS_DIR) / _SOURCES_FILENAME
33
+
34
+
35
+ def load_sources(hooks_dir: Path | None = None) -> dict[str, dict[str, Any]]:
36
+ """Load sources.json. Returns {} if missing or corrupt."""
37
+ path = _sources_path(hooks_dir)
38
+ if not path.is_file():
39
+ return {}
40
+ try:
41
+ with open(path, encoding="utf-8") as f:
42
+ data = json.load(f)
43
+ if isinstance(data, dict):
44
+ return data.get("hooks", {})
45
+ return {}
46
+ except (json.JSONDecodeError, OSError):
47
+ return {}
48
+
49
+
50
+ def save_sources(hooks_dir: Path | None = None,
51
+ sources: dict[str, dict[str, Any]] | None = None) -> None:
52
+ """Write sources.json atomically."""
53
+ hooks_dir = hooks_dir or EXTERNAL_HOOKS_DIR
54
+ path = _sources_path(hooks_dir)
55
+ path.parent.mkdir(parents=True, exist_ok=True)
56
+
57
+ payload = json.dumps({"schema_version": 1, "hooks": sources or {}}, indent=2)
58
+
59
+ fd, tmp_path = tempfile.mkstemp(
60
+ dir=str(path.parent), prefix=".sources_", suffix=".tmp"
61
+ )
62
+ try:
63
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
64
+ f.write(payload)
65
+ f.write("\n")
66
+ f.flush()
67
+ os.fsync(f.fileno())
68
+ os.rename(tmp_path, str(path))
69
+ except BaseException:
70
+ try:
71
+ os.unlink(tmp_path)
72
+ except OSError:
73
+ pass
74
+ raise
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # CRUD
79
+ # ---------------------------------------------------------------------------
80
+
81
+ def register_url_source(hooks_dir: Path | None, hook_name: str, url: str) -> None:
82
+ """Add or update a URL source entry."""
83
+ hooks_dir = hooks_dir or EXTERNAL_HOOKS_DIR
84
+ sources = load_sources(hooks_dir)
85
+ sources[hook_name] = {
86
+ "url": url,
87
+ "fetched_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
88
+ }
89
+ save_sources(hooks_dir, sources)
90
+
91
+
92
+ def unregister_source(hooks_dir: Path | None, hook_name: str) -> bool:
93
+ """Remove a source entry. Returns True if found and removed."""
94
+ hooks_dir = hooks_dir or EXTERNAL_HOOKS_DIR
95
+ sources = load_sources(hooks_dir)
96
+ if hook_name in sources:
97
+ del sources[hook_name]
98
+ save_sources(hooks_dir, sources)
99
+ return True
100
+ return False
101
+
102
+
103
+ def get_url_hooks(hooks_dir: Path | None = None) -> dict[str, str]:
104
+ """Return {hook_name: url} for all URL-sourced hooks."""
105
+ sources = load_sources(hooks_dir)
106
+ return {name: entry["url"] for name, entry in sources.items() if "url" in entry}
@@ -7,15 +7,20 @@ hooks alongside ai-toolkit's hooks. Each injected file is tagged with a
7
7
  and removal is safe.
8
8
 
9
9
  Usage:
10
- inject_hook_cli.py <hooks-file.json> [target-dir]
10
+ inject_hook_cli.py <hooks-file-or-url> [hook-name] [target-dir]
11
11
  inject_hook_cli.py --remove <hook-source-name> [target-dir]
12
12
 
13
13
  Arguments:
14
- hooks-file Path to a JSON file with ``{"hooks": {"EventName": [...]}}``
15
- target-dir Directory containing ``.claude/settings.json`` (default: $HOME)
14
+ hooks-file-or-url Path to a JSON file or HTTPS URL with
15
+ ``{"hooks": {"EventName": [...]}}``
16
+ hook-name Override the source name (default: filename stem or
17
+ URL last segment)
18
+ target-dir Directory containing ``.claude/settings.json``
19
+ (default: $HOME)
16
20
 
17
21
  Flags:
18
22
  --remove Remove all hook entries tagged with the given source name
23
+ (also unregisters URL source if present)
19
24
 
20
25
  The source name is derived from the filename stem (e.g.,
21
26
  ``rag-mcp-hooks.json`` becomes ``"rag-mcp-hooks"``). All entries are tagged
@@ -34,9 +39,13 @@ from __future__ import annotations
34
39
 
35
40
  import json
36
41
  import os
42
+ import re
37
43
  import sys
44
+ import urllib.parse
38
45
  from pathlib import Path
39
46
 
47
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
48
+
40
49
  # Protected source tag -- this CLI must never touch ai-toolkit's own entries.
41
50
  PROTECTED_SOURCE = "ai-toolkit"
42
51
 
@@ -70,6 +79,23 @@ def save_json(path: str, data: dict) -> None:
70
79
  f.write("\n")
71
80
 
72
81
 
82
+ # ---------------------------------------------------------------------------
83
+ # URL helpers
84
+ # ---------------------------------------------------------------------------
85
+
86
+ def _is_url(source: str) -> bool:
87
+ """Check if source looks like an HTTP(S) URL."""
88
+ return source.startswith("https://") or source.startswith("http://")
89
+
90
+
91
+ def _name_from_url(url: str) -> str:
92
+ """Derive a hook source name from a URL's last path segment."""
93
+ parsed = urllib.parse.urlparse(url)
94
+ filename = parsed.path.rstrip("/").split("/")[-1]
95
+ stem = filename.rsplit(".", 1)[0] if "." in filename else filename
96
+ return re.sub(r"[^a-zA-Z0-9_-]", "", stem)
97
+
98
+
73
99
  # ---------------------------------------------------------------------------
74
100
  # Core logic
75
101
  # ---------------------------------------------------------------------------
@@ -157,23 +183,93 @@ def merge_hooks(new_hooks: dict, existing_hooks: dict, source: str) -> dict:
157
183
  # CLI actions
158
184
  # ---------------------------------------------------------------------------
159
185
 
160
- def inject(hooks_file: str, target_dir: str) -> None:
161
- """Inject hooks from *hooks_file* into the target settings.json.
186
+ def _fetch_and_cache(url: str, source: str) -> str:
187
+ """Fetch hooks JSON from URL, cache locally, register source.
162
188
 
163
189
  Args:
164
- hooks_file: Path to the external hooks JSON file.
165
- target_dir: Directory containing ``.claude/settings.json``.
190
+ url: HTTPS URL to fetch.
191
+ source: Source name for caching and registry.
192
+
193
+ Returns:
194
+ Path to the cached hooks JSON file.
166
195
  """
167
- # Derive source name from filename stem
168
- source = Path(hooks_file).stem
169
- if source == PROTECTED_SOURCE:
170
- print(
171
- f"Error: source name '{PROTECTED_SOURCE}' is reserved. "
172
- "Rename your hooks file.",
173
- file=sys.stderr,
174
- )
196
+ from url_fetch import fetch_url
197
+ from hook_sources import register_url_source
198
+ from paths import EXTERNAL_HOOKS_DIR
199
+
200
+ EXTERNAL_HOOKS_DIR.mkdir(parents=True, exist_ok=True)
201
+
202
+ try:
203
+ data = fetch_url(url)
204
+ except Exception as exc:
205
+ print(f"Error fetching URL: {exc}", file=sys.stderr)
175
206
  sys.exit(1)
176
207
 
208
+ # Validate JSON before caching
209
+ try:
210
+ parsed = json.loads(data)
211
+ except json.JSONDecodeError as exc:
212
+ print(f"Error: URL returned invalid JSON: {exc}", file=sys.stderr)
213
+ sys.exit(2)
214
+
215
+ if "hooks" not in parsed:
216
+ print(f"Warning: no 'hooks' key found in URL response", file=sys.stderr)
217
+
218
+ cached_path = EXTERNAL_HOOKS_DIR / f"{source}.json"
219
+ cached_path.write_bytes(data)
220
+ register_url_source(None, source, url)
221
+
222
+ return str(cached_path)
223
+
224
+
225
+ def inject(hooks_file: str, target_dir: str, source_override: str = "") -> None:
226
+ """Inject hooks from *hooks_file* (or URL) into the target settings.json.
227
+
228
+ Args:
229
+ hooks_file: Path to the external hooks JSON file, or an HTTPS URL.
230
+ target_dir: Directory containing ``.claude/settings.json``.
231
+ source_override: Explicit source name (overrides filename-derived name).
232
+ """
233
+ is_url = _is_url(hooks_file)
234
+
235
+ if is_url:
236
+ if hooks_file.startswith("http://"):
237
+ print(
238
+ "Error: only HTTPS URLs are supported. Use https:// for security.",
239
+ file=sys.stderr,
240
+ )
241
+ sys.exit(1)
242
+
243
+ source = source_override or _name_from_url(hooks_file)
244
+ source = re.sub(r"[^a-zA-Z0-9_-]", "", source)
245
+ if not source:
246
+ print(
247
+ "Error: could not derive hook name from URL. "
248
+ "Provide one explicitly.",
249
+ file=sys.stderr,
250
+ )
251
+ sys.exit(1)
252
+
253
+ if source == PROTECTED_SOURCE:
254
+ print(
255
+ f"Error: source name '{PROTECTED_SOURCE}' is reserved.",
256
+ file=sys.stderr,
257
+ )
258
+ sys.exit(1)
259
+
260
+ hooks_file = _fetch_and_cache(hooks_file, source)
261
+ print(f"Fetched hooks from URL (source: '{source}')")
262
+ else:
263
+ # Derive source name from filename stem
264
+ source = source_override or Path(hooks_file).stem
265
+ if source == PROTECTED_SOURCE:
266
+ print(
267
+ f"Error: source name '{PROTECTED_SOURCE}' is reserved. "
268
+ "Rename your hooks file.",
269
+ file=sys.stderr,
270
+ )
271
+ sys.exit(1)
272
+
177
273
  # Load the hooks file
178
274
  try:
179
275
  hooks_data = load_json(hooks_file)
@@ -218,6 +314,8 @@ def inject(hooks_file: str, target_dir: str) -> None:
218
314
  def remove(source_name: str, target_dir: str) -> None:
219
315
  """Remove all hook entries tagged with *source_name*.
220
316
 
317
+ Also unregisters the URL source if it was URL-sourced.
318
+
221
319
  Args:
222
320
  source_name: The ``_source`` tag to remove.
223
321
  target_dir: Directory containing ``.claude/settings.json``.
@@ -256,6 +354,21 @@ def remove(source_name: str, target_dir: str) -> None:
256
354
  save_json(str(settings_path), settings)
257
355
  print(f"Removed hooks with source '{source_name}' from {settings_path}")
258
356
 
357
+ # Unregister URL source if present
358
+ try:
359
+ from hook_sources import unregister_source
360
+ from paths import EXTERNAL_HOOKS_DIR
361
+
362
+ if unregister_source(None, source_name):
363
+ print(f"Unregistered URL source '{source_name}'")
364
+
365
+ # Remove cached file if exists
366
+ cached = EXTERNAL_HOOKS_DIR / f"{source_name}.json"
367
+ if cached.is_file():
368
+ cached.unlink()
369
+ except ImportError:
370
+ pass
371
+
259
372
 
260
373
  # ---------------------------------------------------------------------------
261
374
  # Argument parsing
@@ -265,16 +378,19 @@ def _parse_args(argv: list[str]) -> dict:
265
378
  """Parse CLI arguments.
266
379
 
267
380
  Returns:
268
- Dict with keys: remove_mode, remove_name, source_file, target_dir.
381
+ Dict with keys: remove_mode, remove_name, source_file, hook_name,
382
+ target_dir.
269
383
  """
270
384
  result: dict = {
271
385
  "remove_mode": False,
272
386
  "remove_name": "",
273
387
  "source_file": "",
388
+ "hook_name": "",
274
389
  "target_dir": str(Path.home()),
275
390
  }
276
391
 
277
392
  i = 0
393
+ positional = 0
278
394
  while i < len(argv):
279
395
  arg = argv[i]
280
396
  if arg == "--remove":
@@ -287,10 +403,25 @@ def _parse_args(argv: list[str]) -> dict:
287
403
  elif arg.startswith("-"):
288
404
  print(f"Unknown option: {arg}", file=sys.stderr)
289
405
  sys.exit(1)
290
- elif not result["source_file"] and not result["remove_mode"]:
291
- result["source_file"] = arg
292
406
  else:
293
- result["target_dir"] = arg
407
+ if positional == 0:
408
+ if not result["remove_mode"]:
409
+ result["source_file"] = arg
410
+ else:
411
+ result["target_dir"] = arg
412
+ elif positional == 1:
413
+ if _is_url(result["source_file"]):
414
+ # Second positional after URL could be hook-name or target-dir
415
+ # If it looks like a path (starts with / or ~ or .), it's target-dir
416
+ if arg.startswith(("/", "~", ".")):
417
+ result["target_dir"] = arg
418
+ else:
419
+ result["hook_name"] = arg
420
+ else:
421
+ result["target_dir"] = arg
422
+ elif positional == 2:
423
+ result["target_dir"] = arg
424
+ positional += 1
294
425
  i += 1
295
426
 
296
427
  return result
@@ -309,7 +440,7 @@ def main() -> None:
309
440
  source_file = args["source_file"]
310
441
  if not source_file:
311
442
  print(
312
- "Usage: inject_hook_cli.py <hooks-file.json> [target-dir]",
443
+ "Usage: inject_hook_cli.py <hooks-file-or-url> [hook-name] [target-dir]",
313
444
  file=sys.stderr,
314
445
  )
315
446
  print(
@@ -318,12 +449,13 @@ def main() -> None:
318
449
  )
319
450
  sys.exit(1)
320
451
 
321
- source_path = Path(source_file)
322
- if not source_path.is_file():
323
- print(f"Hooks file not found: {source_path}", file=sys.stderr)
324
- sys.exit(1)
452
+ if not _is_url(source_file):
453
+ source_path = Path(source_file)
454
+ if not source_path.is_file():
455
+ print(f"Hooks file not found: {source_path}", file=sys.stderr)
456
+ sys.exit(1)
325
457
 
326
- inject(source_file, args["target_dir"])
458
+ inject(source_file, args["target_dir"], source_override=args["hook_name"])
327
459
 
328
460
 
329
461
  if __name__ == "__main__":
@@ -47,7 +47,7 @@ from emission import agent_count as count_agents, skill_count as count_skills
47
47
  # Step modules
48
48
  from install_steps.symlinks import install_agents, install_skills, clean_legacy_commands
49
49
  from install_steps.hooks import install_hooks
50
- from install_steps.markers import install_marker_files, inject_rules
50
+ from install_steps.markers import install_marker_files, inject_rules, refresh_url_hooks
51
51
  from install_steps.ai_tools import install_ai_tools, install_local_project, run_script
52
52
  from install_steps.install_state import (
53
53
  load_state,
@@ -432,6 +432,9 @@ def install_claude_code(target_dir: Path, hooks_scripts_dir: Path,
432
432
  inject_rules(claude_dir, target_dir, rules_dir, only, skip, dry_run,
433
433
  refresh_urls=True)
434
434
 
435
+ if not dry_run:
436
+ refresh_url_hooks(str(target_dir))
437
+
435
438
  _sync_mcp_templates(dry_run)
436
439
 
437
440
 
@@ -99,6 +99,47 @@ 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
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
+ print(f" Refreshed: {hook_name} (from {url})")
128
+ except Exception as exc:
129
+ if cached_file.is_file():
130
+ print(f" Warning: could not refresh '{hook_name}' from {url}: {exc}")
131
+ print(f" Using cached version.")
132
+ else:
133
+ print(f" Warning: could not fetch '{hook_name}' from {url}: {exc}")
134
+ print(f" No cached version — hook will be skipped.")
135
+ continue
136
+
137
+ # Re-inject from cached file
138
+ if cached_file.is_file():
139
+ from inject_hook_cli import inject
140
+ inject(str(cached_file), target, source_override=hook_name)
141
+
142
+
102
143
  def _inject_rules_dry_run(rules_dir: Path) -> None:
103
144
  rules_src = app_dir / "rules"
104
145
  rule_names = " ".join(
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"
@@ -12,17 +12,15 @@ 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
26
  _FETCH_TIMEOUT = 30 # seconds
@@ -112,27 +110,5 @@ def get_url_rules(rules_dir: Path | None = None) -> dict[str, str]:
112
110
 
113
111
 
114
112
  # ---------------------------------------------------------------------------
115
- # Fetch
113
+ # Fetch — delegated to shared url_fetch module (re-exported above)
116
114
  # ---------------------------------------------------------------------------
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
@@ -0,0 +1,45 @@
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
+
41
+ # Basic binary detection — reject if null bytes present
42
+ if b"\x00" in data:
43
+ raise ValueError(f"URL returned binary content: {url}")
44
+
45
+ return data
@@ -646,6 +646,23 @@ def _validate_version_sync(tk_dir: Path, vr: ValidationResult) -> None:
646
646
  detail = ", ".join(f"{k}={v}" for k, v in versions.items())
647
647
  vr.error(f"Version mismatch across files: {detail}")
648
648
 
649
+ # Check README "What's New" section matches current version
650
+ pkg_version = versions.get("package.json", "")
651
+ if pkg_version:
652
+ readme = tk_dir / "README.md"
653
+ if readme.is_file():
654
+ content = readme.read_text(encoding="utf-8")
655
+ m = re.search(r"## What's New in v([\d.]+)", content)
656
+ if m:
657
+ whats_new_ver = m.group(1)
658
+ if whats_new_ver == pkg_version:
659
+ print(f" OK: README \"What's New\" (v{whats_new_ver})")
660
+ else:
661
+ vr.error(
662
+ f"README \"What's New in v{whats_new_ver}\" "
663
+ f"is stale (package.json is v{pkg_version})"
664
+ )
665
+
649
666
 
650
667
  def validate_content_quality(tk_dir: Path, vr: ValidationResult) -> None:
651
668
  """Check content quality: name matches directory, non-empty body."""