@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 +19 -0
- package/README.md +7 -7
- 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/procedures/release-preparation-sop.md +12 -0
- 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 +39 -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/scripts/validate.py +17 -0
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)
|
|
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
|
-
- **
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
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 (
|
|
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.
|
|
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
|
/**
|
|
@@ -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
|
|
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
|
@@ -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
|
|
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.
|
|
6537
|
+
version: "1.4.0"
|
|
6526
6538
|
created: "2026-04-07"
|
|
6527
|
-
last_updated: "2026-04-
|
|
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
|
|
6546
|
-
| `remove-hook <name>` | `~/.claude/settings.json` | Strip all entries with matching `_source
|
|
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
|
|
6683
|
+
│ inject-hook <file|url> → settings.json │
|
|
6663
6684
|
│ remove-hook <name> → settings.json │
|
|
6664
|
-
│ add-rule <file
|
|
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
|
-
│
|
|
6690
|
+
│ URL sources: cached + auto-refreshed on update │
|
|
6670
6691
|
└──────────────────────────────────────────────────────┘
|
|
6671
6692
|
▲
|
|
6672
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
|
package/scripts/validate.py
CHANGED
|
@@ -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."""
|