@softspark/ai-toolkit 2.3.1 → 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 +11 -0
- package/README.md +7 -8
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/ARCHITECTURE.md +2 -2
- package/app/rules/common/coding-style.md +16 -2
- package/bin/ai-toolkit.js +16 -12
- package/kb/reference/architecture-overview.md +2 -2
- package/kb/reference/cli-reference.md +3 -3
- package/kb/reference/extension-api.md +22 -13
- package/llms-full.txt +27 -18
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/hook_sources.py +106 -0
- package/scripts/inject_hook_cli.py +157 -25
- package/scripts/install.py +4 -1
- package/scripts/install_steps/markers.py +41 -0
- package/scripts/paths.py +1 -0
- package/scripts/rule_sources.py +2 -26
- package/scripts/url_fetch.py +45 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,17 @@ 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
|
+
|
|
10
21
|
## v2.3.1 — Release Quality Gate (2026-04-14)
|
|
11
22
|
|
|
12
23
|
### Fixed
|
package/README.md
CHANGED
|
@@ -6,17 +6,16 @@
|
|
|
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.
|
|
13
|
+
## What's New in v2.4.0
|
|
14
14
|
|
|
15
|
-
- **URL
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
19
|
-
- **Claude MCP paths fixed** — now uses `~/.claude.json` (global) and `.mcp.json` (project) per official docs
|
|
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
|
|
20
19
|
|
|
21
20
|
See [CHANGELOG.md](CHANGELOG.md) for full history.
|
|
22
21
|
|
|
@@ -143,7 +142,7 @@ ai-toolkit/
|
|
|
143
142
|
│ └── ARCHITECTURE.md # Full system design
|
|
144
143
|
├── kb/ # Reference docs, procedures, plans
|
|
145
144
|
├── scripts/ # Validation, install, evaluation scripts
|
|
146
|
-
├── tests/ # Bats test suite (
|
|
145
|
+
├── tests/ # Bats test suite (613 tests)
|
|
147
146
|
└── CHANGELOG.md
|
|
148
147
|
```
|
|
149
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.
|
|
4
|
+
"version": "2.4.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "SoftSpark",
|
|
7
7
|
"url": "https://github.com/softspark"
|
package/app/ARCHITECTURE.md
CHANGED
|
@@ -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 (
|
|
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>
|
|
230
|
-
console.log(' [
|
|
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 (
|
|
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
|
|
358
|
-
if (!
|
|
359
|
-
console.error('Usage: ai-toolkit inject-hook <hooks-file
|
|
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
|
|
363
|
-
|
|
364
|
-
|
|
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
|
/**
|
|
@@ -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
|
|
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.
|
|
6
|
+
version: "1.4.0"
|
|
7
7
|
created: "2026-04-07"
|
|
8
|
-
last_updated: "2026-04-
|
|
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
|
|
27
|
-
| `remove-hook <name>` | `~/.claude/settings.json` | Strip all entries with matching `_source
|
|
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
|
|
152
|
+
│ inject-hook <file|url> → settings.json │
|
|
144
153
|
│ remove-hook <name> → settings.json │
|
|
145
|
-
│ add-rule <file
|
|
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
|
-
│
|
|
159
|
+
│ URL sources: cached + auto-refreshed on update │
|
|
151
160
|
└──────────────────────────────────────────────────────┘
|
|
152
161
|
▲
|
|
153
162
|
│ uses API
|
package/llms-full.txt
CHANGED
|
@@ -4791,8 +4791,8 @@ Severity levels: HIGH (blocks deployment), WARN (should fix), INFO (best practic
|
|
|
4791
4791
|
### Language Rules
|
|
4792
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.
|
|
4793
4793
|
|
|
4794
|
-
### Extension API (`inject-hook`)
|
|
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.
|
|
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`).
|
|
4796
4796
|
|
|
4797
4797
|
### Manifest Install (`--modules`, `--auto-detect`)
|
|
4798
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.
|
|
@@ -5167,10 +5167,10 @@ Usage: ai-toolkit <command> [options]
|
|
|
5167
5167
|
|
|
5168
5168
|
| Command | Description |
|
|
5169
5169
|
|---------|-------------|
|
|
5170
|
-
| `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` |
|
|
5171
5171
|
| `remove-rule <name> [dir]` | Unregister rule and remove its block from `CLAUDE.md` |
|
|
5172
|
-
| `inject-hook <file.json
|
|
5173
|
-
| `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) |
|
|
5174
5174
|
|
|
5175
5175
|
## MCP Management
|
|
5176
5176
|
|
|
@@ -6534,9 +6534,9 @@ title: "Extension API Reference"
|
|
|
6534
6534
|
category: reference
|
|
6535
6535
|
service: ai-toolkit
|
|
6536
6536
|
tags: [extension-api, inject-rule, inject-hook, mcp-templates, integration, editors]
|
|
6537
|
-
version: "1.
|
|
6537
|
+
version: "1.4.0"
|
|
6538
6538
|
created: "2026-04-07"
|
|
6539
|
-
last_updated: "2026-04-
|
|
6539
|
+
last_updated: "2026-04-15"
|
|
6540
6540
|
description: "Reference for ai-toolkit's extension API: inject-rule, inject-hook, remove-rule, remove-hook, and editor-aware MCP template management."
|
|
6541
6541
|
---
|
|
6542
6542
|
|
|
@@ -6554,9 +6554,9 @@ This design is intentional: ai-toolkit is a generic toolkit. Consumers (MCP serv
|
|
|
6554
6554
|
|---------|-------------|-----------|------------|
|
|
6555
6555
|
| `inject-rule <file.md>` | `~/.claude/CLAUDE.md` | HTML comment markers (`<!-- TOOLKIT:name -->`) | Yes |
|
|
6556
6556
|
| `remove-rule <name>` | `~/.claude/CLAUDE.md` | Strip markers by block name | Yes |
|
|
6557
|
-
| `inject-hook <file.json
|
|
6558
|
-
| `remove-hook <name>` | `~/.claude/settings.json` | Strip all entries with matching `_source
|
|
6559
|
-
| `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 |
|
|
6560
6560
|
| `mcp add <name...>` | `.mcp.json` | Merge `mcpServers` block from template | Yes |
|
|
6561
6561
|
| `mcp install --editor <name...>` | Native editor MCP config | Render canonical template into editor format | Yes |
|
|
6562
6562
|
|
|
@@ -6591,13 +6591,20 @@ The argument is the block name (file stem used during `inject-rule`). If the blo
|
|
|
6591
6591
|
|
|
6592
6592
|
## inject-hook
|
|
6593
6593
|
|
|
6594
|
-
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.
|
|
6595
6595
|
|
|
6596
6596
|
```bash
|
|
6597
|
+
# From local file
|
|
6597
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
|
|
6598
6605
|
```
|
|
6599
6606
|
|
|
6600
|
-
**Implementation:** `scripts/inject_hook_cli.py`.
|
|
6607
|
+
**Implementation:** `scripts/inject_hook_cli.py`, `scripts/hook_sources.py`, `scripts/url_fetch.py`.
|
|
6601
6608
|
|
|
6602
6609
|
**Input format:**
|
|
6603
6610
|
```json
|
|
@@ -6619,15 +6626,17 @@ npx @softspark/ai-toolkit inject-hook ./my-tool-hooks.json
|
|
|
6619
6626
|
}
|
|
6620
6627
|
```
|
|
6621
6628
|
|
|
6622
|
-
**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.
|
|
6623
6632
|
|
|
6624
6633
|
**Idempotency:** Re-running strips all existing entries with the same source name, then appends the new ones. No duplicates accumulate.
|
|
6625
6634
|
|
|
6626
|
-
**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.
|
|
6627
6636
|
|
|
6628
6637
|
## remove-hook
|
|
6629
6638
|
|
|
6630
|
-
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.
|
|
6631
6640
|
|
|
6632
6641
|
```bash
|
|
6633
6642
|
npx @softspark/ai-toolkit remove-hook my-tool-hooks
|
|
@@ -6671,14 +6680,14 @@ When `install` runs with `--scope project`, ai-toolkit also updates `.mcp.json`
|
|
|
6671
6680
|
│ Public Extension API: │
|
|
6672
6681
|
│ inject-rule <file.md> → CLAUDE.md │
|
|
6673
6682
|
│ remove-rule <name> → CLAUDE.md │
|
|
6674
|
-
│ inject-hook <file
|
|
6683
|
+
│ inject-hook <file|url> → settings.json │
|
|
6675
6684
|
│ remove-hook <name> → settings.json │
|
|
6676
|
-
│ add-rule <file
|
|
6685
|
+
│ add-rule <file|url> → rules/ registry │
|
|
6677
6686
|
│ mcp add <template> → .mcp.json │
|
|
6678
6687
|
│ mcp install <template> → editor-native MCP │
|
|
6679
6688
|
│ │
|
|
6680
6689
|
│ Idempotent: markers (rules) / _source tags (hooks) │
|
|
6681
|
-
│
|
|
6690
|
+
│ URL sources: cached + auto-refreshed on update │
|
|
6682
6691
|
└──────────────────────────────────────────────────────┘
|
|
6683
6692
|
▲
|
|
6684
6693
|
│ uses API
|
package/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softspark/ai-toolkit",
|
|
3
|
-
"version": "2.
|
|
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
|
|
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
|
|
15
|
-
|
|
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
|
|
161
|
-
"""
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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__":
|
package/scripts/install.py
CHANGED
|
@@ -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"
|
package/scripts/rule_sources.py
CHANGED
|
@@ -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
|