@softspark/ai-toolkit 4.17.0 → 4.18.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 +52 -0
- package/README.md +10 -10
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/plugins/README.md +16 -4
- package/app/plugins/rtk-pack/README.md +123 -0
- package/app/plugins/rtk-pack/hooks/rewrite.sh +79 -0
- package/app/plugins/rtk-pack/plugin.json +60 -0
- package/app/plugins/rtk-pack/scripts/init.py +252 -0
- package/app/plugins/rtk-pack/scripts/status.py +105 -0
- package/bin/ai-toolkit.js +15 -0
- package/kb/history/completed/rtk-pack-integration-20260726.md +710 -0
- package/kb/procedures/maintenance-sop.md +1 -1
- package/kb/procedures/release-preparation-sop.md +8 -3
- package/kb/procedures/rtk-upstream-sync-sop.md +279 -0
- package/kb/reference/cli-reference.md +1 -1
- package/kb/reference/plugin-pack-conventions.md +17 -2
- package/llms-full.txt +1028 -7
- package/llms.txt +2 -0
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/audit_skills.py +21 -0
- package/scripts/plugin.py +136 -16
- package/scripts/verify_rtk_binary.py +335 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,58 @@ Versioning follows [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## v4.18.0 — rtk-pack, an opt-in command rewriter (2026-07-26)
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`rtk-pack`**, an opt-in plugin pack that rewrites Bash commands at
|
|
15
|
+
`PreToolUse` via [rtk](https://github.com/rtk-ai/rtk) (Apache-2.0). Nothing
|
|
16
|
+
installs it for you. `ai-toolkit plugin install rtk-pack` fetches a
|
|
17
|
+
platform-specific binary from an ai-toolkit GitHub Release and verifies its
|
|
18
|
+
SHA-256 before installing anything; a mismatch aborts and leaves nothing
|
|
19
|
+
behind. Read `app/plugins/rtk-pack/README.md` before enabling it: the rewrite
|
|
20
|
+
means what runs is not literally what the model asked for, and rtk applies
|
|
21
|
+
your permission verdict for the original command to the rewritten one.
|
|
22
|
+
- **`.github/workflows/rtk-build.yml`**, a manual-dispatch pipeline that
|
|
23
|
+
cross-builds rtk from upstream source for five targets with the compile-time
|
|
24
|
+
telemetry endpoint left undefined, proves each artifact starts, writes no
|
|
25
|
+
telemetry state and (on Linux) behaves identically with no network route,
|
|
26
|
+
then publishes checksummed artifacts with LICENSE and NOTICE.
|
|
27
|
+
- **`scripts/verify_rtk_binary.py`**, the silence verifier that pipeline uses.
|
|
28
|
+
A target it cannot start reports `inconclusive`, never `pass`.
|
|
29
|
+
- **`kb/procedures/rtk-upstream-sync-sop.md`**, the procedure for moving to a
|
|
30
|
+
newer upstream tag, including a re-measure gate and a published kill number.
|
|
31
|
+
- Plugin packs can now ship `scripts/status.py` and have `plugin status` pick it
|
|
32
|
+
up. This replaces a hardcoded `if name == "memory-pack"` branch, so any pack
|
|
33
|
+
can report its own health.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- **`ai-toolkit update` now updates installed plugin packs.** It reads
|
|
38
|
+
`plugins.json` and runs the equivalent of `plugin update --all` for every
|
|
39
|
+
installed pack. `--local` leaves packs alone, since they are global.
|
|
40
|
+
- **`plugin update` is version-aware.** `plugins.json` records the pack version
|
|
41
|
+
installed per editor, and an update whose manifest version matches is a silent
|
|
42
|
+
no-op. Previously every update removed and reinstalled unconditionally, which
|
|
43
|
+
for a pack that downloads a binary meant refetching it every time. `--force`
|
|
44
|
+
overrides; `--dry-run` reports without acting. State written before versions
|
|
45
|
+
were tracked updates each pack exactly once.
|
|
46
|
+
- Plugin packs are now covered by `audit_skills.py --ci` and by the ShellCheck
|
|
47
|
+
gate in CI. Both previously scanned only `app/skills`, `app/agents` and
|
|
48
|
+
`app/hooks`, exempting the highest-risk code in the repo.
|
|
49
|
+
- A pack's install-time script may be named `init.py`; `init_db.py` still works.
|
|
50
|
+
Previously only the latter ran, so a pack using any other name installed
|
|
51
|
+
successfully while doing nothing.
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
|
|
55
|
+
- `plugin install` no longer aborts with `IsADirectoryError` when a pack ships a
|
|
56
|
+
subdirectory under `scripts/`.
|
|
57
|
+
- A failing pack init script now always reports, instead of printing nothing
|
|
58
|
+
when it exited non-zero with empty stderr.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
10
62
|
## v4.17.0 — Native tool-output filter removed (2026-07-26)
|
|
11
63
|
|
|
12
64
|
### Removed
|
package/README.md
CHANGED
|
@@ -6,17 +6,17 @@
|
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](app/skills/)
|
|
8
8
|
[](app/agents/)
|
|
9
|
-
[](tests/)
|
|
10
10
|
|
|
11
|
-
## What's New in v4.
|
|
11
|
+
## What's New in v4.18.0
|
|
12
12
|
|
|
13
|
-
v4.
|
|
13
|
+
v4.18.0 adds `rtk-pack`, an opt-in command rewriter, and makes `ai-toolkit update` maintain installed packs.
|
|
14
14
|
|
|
15
|
-
-
|
|
16
|
-
- **
|
|
17
|
-
-
|
|
18
|
-
- **
|
|
19
|
-
- **
|
|
15
|
+
- **`rtk-pack`, opt-in only**: rewrites Bash commands at `PreToolUse` via [rtk](https://github.com/rtk-ai/rtk) (Apache-2.0). Nothing installs it for you. `plugin install` fetches a platform binary from an ai-toolkit Release and verifies its SHA-256 before installing anything. Measured saving on the reference workload is 0.0615% of input tokens; the pack README states the trust boundary and the numbers up front.
|
|
16
|
+
- **Binaries built from source, telemetry compiled out**: a manual-dispatch workflow cross-builds five targets with the compile-time telemetry endpoint undefined, proves each artifact starts, writes no telemetry state, and on Linux behaves identically with no network route.
|
|
17
|
+
- **`ai-toolkit update` maintains installed packs**: it now runs the equivalent of `plugin update --all`. Updates are version-aware, so a pack whose manifest has not moved is a silent no-op instead of a remove-and-reinstall.
|
|
18
|
+
- **Packs can report their own health**: `plugin status` picks up a pack's `scripts/status.py`, replacing a hardcoded per-pack branch.
|
|
19
|
+
- **Security gates cover packs**: `audit_skills.py --ci` and the ShellCheck gate now scan `app/plugins/`, which they previously skipped.
|
|
20
20
|
|
|
21
21
|
See [CHANGELOG.md](CHANGELOG.md) for full history.
|
|
22
22
|
|
|
@@ -143,7 +143,7 @@ See [CLI Reference](kb/reference/cli-reference.md) for all commands and options.
|
|
|
143
143
|
| `skills/` (knowledge) | 46 | Domain knowledge auto-loaded by agents (includes 13 `<lang>-rules` skills) |
|
|
144
144
|
| `agents/` | 44 | Specialized agents across 10 categories |
|
|
145
145
|
| `hooks/` | 28 entries / 14 events + statusLine | Quality gates, path safety, prompt governance, loop guard, session lifecycle |
|
|
146
|
-
| `plugins/` |
|
|
146
|
+
| `plugins/` | 12 packs | Opt-in domain bundles (security, research, frontend, enterprise, memory, rtk, 6 language packs) |
|
|
147
147
|
| `constitution.md` | 7 articles | Machine-enforced safety rules |
|
|
148
148
|
| `rules/` | auto-synced | Global/project rule files for Claude and other editors |
|
|
149
149
|
| `kb/` | reference docs | Architecture, procedures, and best practices |
|
|
@@ -166,7 +166,7 @@ ai-toolkit/
|
|
|
166
166
|
│ └── ARCHITECTURE.md # Full system design
|
|
167
167
|
├── kb/ # Reference docs, procedures, plans
|
|
168
168
|
├── scripts/ # Validation, install, evaluation scripts
|
|
169
|
-
├── tests/ # Bats and Python test suite (
|
|
169
|
+
├── tests/ # Bats and Python test suite (1484 tests)
|
|
170
170
|
└── CHANGELOG.md
|
|
171
171
|
```
|
|
172
172
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "ai-toolkit",
|
|
4
4
|
"displayName": "AI Toolkit",
|
|
5
5
|
"description": "Professional-grade engineering skills, agents, rules, and lifecycle guardrails for Claude Code, Claude Chat, and Cowork.",
|
|
6
|
-
"version": "4.
|
|
6
|
+
"version": "4.18.0",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "SoftSpark",
|
|
9
9
|
"url": "https://github.com/softspark"
|
package/app/plugins/README.md
CHANGED
|
@@ -17,10 +17,22 @@ Plugin packs can target:
|
|
|
17
17
|
These packs are not directly uploadable Claude app plugins. For Claude Chat,
|
|
18
18
|
Desktop, and Cowork use `ai-toolkit claude-app export`.
|
|
19
19
|
|
|
20
|
-
Current packs:
|
|
21
|
-
- `
|
|
22
|
-
- `research-pack`
|
|
23
|
-
- `frontend-pack`
|
|
20
|
+
Current packs (12):
|
|
21
|
+
- `csharp-pack`
|
|
24
22
|
- `enterprise-pack`
|
|
23
|
+
- `frontend-pack`
|
|
24
|
+
- `java-pack`
|
|
25
|
+
- `kotlin-pack`
|
|
26
|
+
- `memory-pack`
|
|
27
|
+
- `research-pack`
|
|
28
|
+
- `rtk-pack`
|
|
29
|
+
- `ruby-pack`
|
|
30
|
+
- `rust-pack`
|
|
31
|
+
- `security-pack`
|
|
32
|
+
- `swift-pack`
|
|
33
|
+
|
|
34
|
+
`rtk-pack` is the only one that fetches a native binary at install time and the
|
|
35
|
+
only one that rewrites commands before they run. Read its README before
|
|
36
|
+
installing it.
|
|
25
37
|
|
|
26
38
|
See `kb/reference/plugin-pack-conventions.md` for pack rules, validation, and adoption guidance.
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# rtk-pack
|
|
2
|
+
|
|
3
|
+
Opt-in command rewriting via [rtk](https://github.com/rtk-ai/rtk), Apache-2.0.
|
|
4
|
+
|
|
5
|
+
Install: `ai-toolkit plugin install rtk-pack`. Remove: `ai-toolkit plugin remove rtk-pack`.
|
|
6
|
+
Nothing installs it for you, and nothing in the core depends on it.
|
|
7
|
+
|
|
8
|
+
## Read this before installing
|
|
9
|
+
|
|
10
|
+
**This pack rewrites your commands before they run.** A `PreToolUse` hook hands
|
|
11
|
+
each command to `rtk`, which may replace it with an `rtk` equivalent that
|
|
12
|
+
produces shorter output. What executes is therefore not literally what the model
|
|
13
|
+
asked for.
|
|
14
|
+
|
|
15
|
+
That is a real trade-off and the reason this ships as an opt-in pack rather than
|
|
16
|
+
a core feature. ai-toolkit's own retired output filter explicitly refused to do
|
|
17
|
+
this. Adopting rtk is a deliberate reversal, made with the mechanism understood
|
|
18
|
+
rather than assumed.
|
|
19
|
+
|
|
20
|
+
**Your permission rules are evaluated against the original command, and the
|
|
21
|
+
verdict is applied to the rewritten one.** rtk checks what the model asked for
|
|
22
|
+
against your `settings.json`, and if a rule allows it, emits
|
|
23
|
+
`permissionDecision: allow` for the `rtk …` form it substituted. So an allowlist
|
|
24
|
+
entry for `git status` transitively authorises `rtk git status`, a command you
|
|
25
|
+
never wrote a rule for. With no matching allow rule, no decision is emitted and
|
|
26
|
+
your normal prompt appears. Verified against upstream's own test at
|
|
27
|
+
`src/hooks/hook_cmd.rs:1272-1283`.
|
|
28
|
+
|
|
29
|
+
If your allowlist is broad, review it before installing. If it is narrow, expect
|
|
30
|
+
prompts on commands that used to run without one.
|
|
31
|
+
|
|
32
|
+
Every failure path degrades to passthrough: no binary, a non-zero exit, a
|
|
33
|
+
timeout, or unparseable output all result in the original command running
|
|
34
|
+
unchanged. Removing the pack strips the hook from `settings.json`.
|
|
35
|
+
|
|
36
|
+
## What you get, measured
|
|
37
|
+
|
|
38
|
+
On the reference workload (1224 local transcripts, 26.6 MB of successful Bash
|
|
39
|
+
output) rtk addresses **35.2%** of Bash bytes, which is **8.8%** of all
|
|
40
|
+
tool-result bytes. The projected saving is **0.12% to 0.16%** of session input
|
|
41
|
+
tokens, or 0.24% to 0.36% if rtk's own 60-90% claim holds on your traffic.
|
|
42
|
+
|
|
43
|
+
That is small, and it is stated up front on purpose. The arithmetic ceiling for
|
|
44
|
+
any tool-output mechanism on that workload is 4.5%, because tool results are
|
|
45
|
+
that share of input token volume, and `Read` results are 63% of them. If your
|
|
46
|
+
work is dominated by `cargo test`, `mvn` or `jest` output rather than file
|
|
47
|
+
reading, rtk is calibrated for you and will do better. Measure before believing
|
|
48
|
+
either number.
|
|
49
|
+
|
|
50
|
+
Full method and caveats: `kb/history/completed/rtk-pack-integration-20260726.md`.
|
|
51
|
+
|
|
52
|
+
## The binary
|
|
53
|
+
|
|
54
|
+
Not bundled. `plugin install` fetches the artifact for your platform from an
|
|
55
|
+
ai-toolkit GitHub Release and verifies its SHA-256 against `plugin.json` before
|
|
56
|
+
installing anything. A digest mismatch aborts and leaves nothing behind. This is
|
|
57
|
+
the only moment the pack uses the network.
|
|
58
|
+
|
|
59
|
+
Binaries are cross-built from upstream source in ai-toolkit CI with
|
|
60
|
+
`RTK_TELEMETRY_URL` and `RTK_TELEMETRY_TOKEN` left undefined, so the
|
|
61
|
+
compile-time telemetry endpoint is absent and cannot be supplied at runtime.
|
|
62
|
+
Each build asserts that, runs the artifact, checks no telemetry state is
|
|
63
|
+
written, and on Linux confirms behaviour is unchanged with no network route.
|
|
64
|
+
|
|
65
|
+
| Platform | Artifact |
|
|
66
|
+
|---|---|
|
|
67
|
+
| macOS arm64 | `rtk-aarch64-apple-darwin.tar.gz` |
|
|
68
|
+
| macOS x86_64 | `rtk-x86_64-apple-darwin.tar.gz` |
|
|
69
|
+
| Linux x86_64 | `rtk-x86_64-unknown-linux-musl.tar.gz` (static, runs on glibc) |
|
|
70
|
+
| Linux aarch64 | `rtk-aarch64-unknown-linux-gnu.tar.gz` |
|
|
71
|
+
| Windows x86_64 | `rtk-x86_64-pc-windows-msvc.zip` |
|
|
72
|
+
|
|
73
|
+
## One thing the binary does reach the network for
|
|
74
|
+
|
|
75
|
+
`rtk cc` (Claude economics) shells out to `npx --yes ccusage` when `ccusage` is
|
|
76
|
+
not already on `PATH`, which fetches and executes a third-party npm package.
|
|
77
|
+
That is not telemetry and never happens automatically, but it is outbound
|
|
78
|
+
network from a binary otherwise described as silent. Do not run `rtk cc` in an
|
|
79
|
+
air-gapped or policy-restricted environment.
|
|
80
|
+
|
|
81
|
+
Related: `rtk telemetry forget` on these builds prints a failure telling you to
|
|
82
|
+
email upstream to complete erasure. Nothing was ever sent; the message is
|
|
83
|
+
upstream's and does not apply here.
|
|
84
|
+
|
|
85
|
+
## Layout
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
~/.softspark/ai-toolkit/
|
|
89
|
+
├── hooks/plugin-rtk-pack-rewrite.sh # the PreToolUse hook
|
|
90
|
+
└── plugin-scripts/rtk-pack/
|
|
91
|
+
├── bin/rtk # verified binary
|
|
92
|
+
└── version.json # pinned versions and recorded digest
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Installing from a mirror
|
|
96
|
+
|
|
97
|
+
Set `RTK_PACK_RELEASE_BASE_URL` to a base URL holding the release assets and the
|
|
98
|
+
install fetches from there instead of GitHub, for networks that cannot reach it
|
|
99
|
+
directly:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
RTK_PACK_RELEASE_BASE_URL=https://mirror.internal/rtk/v0.44.0-1 \
|
|
103
|
+
ai-toolkit plugin install rtk-pack
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The digest check is unchanged. A mirror serving different bytes is rejected
|
|
107
|
+
exactly like a corrupt download, so this widens where the artifact comes from
|
|
108
|
+
without widening what is accepted.
|
|
109
|
+
|
|
110
|
+
## Disabling without removing
|
|
111
|
+
|
|
112
|
+
`rtk` honours `RTK_DISABLED=1` as a per-command prefix. To turn the pack off
|
|
113
|
+
entirely, remove it: `ai-toolkit plugin remove rtk-pack`.
|
|
114
|
+
|
|
115
|
+
## Licence
|
|
116
|
+
|
|
117
|
+
rtk is Apache-2.0. `LICENSE` and `NOTICE` ship with every release artifact. The
|
|
118
|
+
NOTICE records the two build-time differences from an upstream build: the
|
|
119
|
+
undefined telemetry endpoint, and in-range lockfile security updates for
|
|
120
|
+
`anyhow` and `crossbeam-epoch`. No upstream source is modified.
|
|
121
|
+
|
|
122
|
+
Upstream `DISCLAIMER.md` is deliberately not redistributed: it states that
|
|
123
|
+
telemetry is collected by default, which is not true of these builds.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# rewrite.sh — delegate PreToolUse command rewriting to the rtk binary.
|
|
3
|
+
#
|
|
4
|
+
# Fires on: PreToolUse
|
|
5
|
+
# Reads the Claude Code hook JSON from stdin and hands it to `rtk hook claude`,
|
|
6
|
+
# which emits hookSpecificOutput with permissionDecision and updatedInput.
|
|
7
|
+
#
|
|
8
|
+
# This hook rewrites the command before it runs, so what executes is not
|
|
9
|
+
# literally what the model asked for. That is the pack's stated trade-off; see
|
|
10
|
+
# the pack README. Everything here is built so that any failure degrades to
|
|
11
|
+
# passthrough rather than to a broken or altered command:
|
|
12
|
+
#
|
|
13
|
+
# no binary -> no output, exit 0 (command runs unchanged)
|
|
14
|
+
# rtk fails -> no output, exit 0
|
|
15
|
+
# rtk times out -> no output, exit 0
|
|
16
|
+
# rtk prints junk -> no output, exit 0
|
|
17
|
+
#
|
|
18
|
+
# Emitting nothing is the safe default: Claude Code treats an empty hook
|
|
19
|
+
# response as "no opinion" and runs the original command.
|
|
20
|
+
|
|
21
|
+
set -u
|
|
22
|
+
|
|
23
|
+
PACK_BIN_DIR="${HOME}/.softspark/ai-toolkit/plugin-scripts/rtk-pack/bin"
|
|
24
|
+
TIMEOUT_SECONDS=5
|
|
25
|
+
|
|
26
|
+
# Read stdin once; it is not replayable.
|
|
27
|
+
INPUT=$(cat)
|
|
28
|
+
[ -n "$INPUT" ] || exit 0
|
|
29
|
+
|
|
30
|
+
# jq is needed both to gate on the tool name and to check rtk's reply. Without
|
|
31
|
+
# it we cannot do either safely, so stay out of the way entirely.
|
|
32
|
+
command -v jq >/dev/null 2>&1 || exit 0
|
|
33
|
+
|
|
34
|
+
# A pack hook cannot declare its own matcher: plugin.py:408 looks the basename
|
|
35
|
+
# up in core app/hooks.json and falls back to "", so this registers against
|
|
36
|
+
# every tool rather than Bash alone. Gate here instead, otherwise every Read,
|
|
37
|
+
# Edit and Grep would spawn a subprocess for nothing.
|
|
38
|
+
TOOL=$(printf '%s' "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
39
|
+
[ "$TOOL" = "Bash" ] || exit 0
|
|
40
|
+
|
|
41
|
+
RTK=""
|
|
42
|
+
for candidate in "${PACK_BIN_DIR}/rtk" "${PACK_BIN_DIR}/rtk.exe"; do
|
|
43
|
+
if [ -x "$candidate" ]; then
|
|
44
|
+
RTK="$candidate"
|
|
45
|
+
break
|
|
46
|
+
fi
|
|
47
|
+
done
|
|
48
|
+
|
|
49
|
+
# Inert when init.py could not install a binary. The pack stays wired so that a
|
|
50
|
+
# later `ai-toolkit plugin install rtk-pack` fixes it without touching settings.
|
|
51
|
+
[ -n "$RTK" ] || exit 0
|
|
52
|
+
|
|
53
|
+
# GNU timeout is `timeout`, Homebrew coreutils installs it as `gtimeout`, and
|
|
54
|
+
# on a bare macOS it is absent. A hung rewriter would hang the session, so use
|
|
55
|
+
# it when we can and accept the risk when we cannot.
|
|
56
|
+
TIMEOUT_CMD=""
|
|
57
|
+
if command -v timeout >/dev/null 2>&1; then
|
|
58
|
+
TIMEOUT_CMD="timeout"
|
|
59
|
+
elif command -v gtimeout >/dev/null 2>&1; then
|
|
60
|
+
TIMEOUT_CMD="gtimeout"
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
if [ -n "$TIMEOUT_CMD" ]; then
|
|
64
|
+
OUTPUT=$(printf '%s' "$INPUT" | "$TIMEOUT_CMD" "$TIMEOUT_SECONDS" "$RTK" hook claude 2>/dev/null)
|
|
65
|
+
else
|
|
66
|
+
OUTPUT=$(printf '%s' "$INPUT" | "$RTK" hook claude 2>/dev/null)
|
|
67
|
+
fi
|
|
68
|
+
STATUS=$?
|
|
69
|
+
|
|
70
|
+
[ "$STATUS" -eq 0 ] || exit 0
|
|
71
|
+
[ -n "$OUTPUT" ] || exit 0
|
|
72
|
+
|
|
73
|
+
# Only forward something that parses as JSON. A partial write or a stray
|
|
74
|
+
# diagnostic on stdout would otherwise reach Claude Code as a malformed hook
|
|
75
|
+
# response.
|
|
76
|
+
printf '%s' "$OUTPUT" | jq -e . >/dev/null 2>&1 || exit 0
|
|
77
|
+
|
|
78
|
+
printf '%s\n' "$OUTPUT"
|
|
79
|
+
exit 0
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "rtk-pack",
|
|
3
|
+
"description": "Opt-in command rewriting via rtk, cross-built from source with telemetry disabled and fetched from a checksum-pinned release",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"domain": "token-reduction",
|
|
6
|
+
"type": "plugin-pack",
|
|
7
|
+
"status": "experimental",
|
|
8
|
+
"requires": {
|
|
9
|
+
"ai-toolkit": ">=4.17.0",
|
|
10
|
+
"claude-code": ">=1.0.33"
|
|
11
|
+
},
|
|
12
|
+
"includes": {
|
|
13
|
+
"agents": [],
|
|
14
|
+
"skills": [],
|
|
15
|
+
"rules": [],
|
|
16
|
+
"hooks": ["rewrite.sh"]
|
|
17
|
+
},
|
|
18
|
+
"hook_events": {
|
|
19
|
+
"rewrite.sh": "PreToolUse"
|
|
20
|
+
},
|
|
21
|
+
"upstream": {
|
|
22
|
+
"project": "rtk",
|
|
23
|
+
"repository": "https://github.com/rtk-ai/rtk",
|
|
24
|
+
"license": "Apache-2.0",
|
|
25
|
+
"version": "v0.44.0",
|
|
26
|
+
"note": "Built from source in our CI with RTK_TELEMETRY_URL and RTK_TELEMETRY_TOKEN undefined. Cargo.lock carries in-range security updates for anyhow and crossbeam-epoch. No source modifications."
|
|
27
|
+
},
|
|
28
|
+
"binary": {
|
|
29
|
+
"release_repo": "softspark/ai-toolkit",
|
|
30
|
+
"release_tag": "softspark-rtk-v0.44.0-1",
|
|
31
|
+
"install_name": "rtk",
|
|
32
|
+
"assets": {
|
|
33
|
+
"darwin-arm64": {
|
|
34
|
+
"file": "rtk-aarch64-apple-darwin.tar.gz",
|
|
35
|
+
"sha256": "a69b8ccb7c516a1d6e06aff714bdc8bf0afcb2af240c5b2f6c83122743c93c56",
|
|
36
|
+
"member": "rtk"
|
|
37
|
+
},
|
|
38
|
+
"darwin-x86_64": {
|
|
39
|
+
"file": "rtk-x86_64-apple-darwin.tar.gz",
|
|
40
|
+
"sha256": "bda9f4a6acf87be859394d5da3a064db211a869cf331ed2c485e174f5fd81dcb",
|
|
41
|
+
"member": "rtk"
|
|
42
|
+
},
|
|
43
|
+
"linux-aarch64": {
|
|
44
|
+
"file": "rtk-aarch64-unknown-linux-gnu.tar.gz",
|
|
45
|
+
"sha256": "fd5f4b10ca2e96e674b437f9a00639f376b215e7d15373327142f5f8e9642795",
|
|
46
|
+
"member": "rtk"
|
|
47
|
+
},
|
|
48
|
+
"linux-x86_64": {
|
|
49
|
+
"file": "rtk-x86_64-unknown-linux-musl.tar.gz",
|
|
50
|
+
"sha256": "1d886b748181b78e11ea99c2f2ff60ad04a73dbc29ca20ca807f01642ebcea32",
|
|
51
|
+
"member": "rtk"
|
|
52
|
+
},
|
|
53
|
+
"windows-x86_64": {
|
|
54
|
+
"file": "rtk-x86_64-pc-windows-msvc.zip",
|
|
55
|
+
"sha256": "83ebf53af4f15d860cef16e8ff6fe9a5cf25fa10ed4c48a1544d359a2796ef38",
|
|
56
|
+
"member": "rtk.exe"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fetch, verify and install the rtk binary for this platform.
|
|
3
|
+
|
|
4
|
+
Run by `ai-toolkit plugin install rtk-pack` (scripts/plugin.py invokes
|
|
5
|
+
`init.py` if present). This is the only point at which the pack touches the
|
|
6
|
+
network; nothing is fetched at runtime.
|
|
7
|
+
|
|
8
|
+
Design constraints from kb/history/completed/rtk-pack-integration-20260726.md section 6:
|
|
9
|
+
|
|
10
|
+
- Verify before install. A digest mismatch aborts and removes the partial
|
|
11
|
+
download; a half-installed binary is worse than none.
|
|
12
|
+
- A fetch failure is not an install failure. The pack degrades to inert and
|
|
13
|
+
says so, matching how the core behaves when jq is missing. plugin.py only
|
|
14
|
+
warns on a non-zero exit, so the message has to carry the meaning.
|
|
15
|
+
- Never silent. Every path that ends without a working binary prints why.
|
|
16
|
+
|
|
17
|
+
Stdlib only.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import platform
|
|
25
|
+
import shutil
|
|
26
|
+
import ssl
|
|
27
|
+
import sys
|
|
28
|
+
import tarfile
|
|
29
|
+
import tempfile
|
|
30
|
+
import urllib.error
|
|
31
|
+
import urllib.request
|
|
32
|
+
import zipfile
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
PACK_NAME = "rtk-pack"
|
|
36
|
+
TOOLKIT_DATA_DIR = Path(os.environ.get("AI_TOOLKIT_DATA_DIR", Path.home() / ".softspark" / "ai-toolkit"))
|
|
37
|
+
INSTALL_DIR = TOOLKIT_DATA_DIR / "plugin-scripts" / PACK_NAME / "bin"
|
|
38
|
+
VERSION_FILE = TOOLKIT_DATA_DIR / "plugin-scripts" / PACK_NAME / "version.json"
|
|
39
|
+
|
|
40
|
+
DOWNLOAD_TIMEOUT = 120
|
|
41
|
+
CHUNK = 1 << 16
|
|
42
|
+
|
|
43
|
+
# Bounded so a wrong URL cannot fill the disk. The largest real asset is ~4 MB.
|
|
44
|
+
MAX_ASSET_BYTES = 64 * 1024 * 1024
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class InstallError(Exception):
|
|
48
|
+
"""Anything that leaves the pack without a usable binary."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def manifest_path() -> Path:
|
|
52
|
+
"""The pack manifest, resolved relative to this script's source location."""
|
|
53
|
+
return Path(__file__).resolve().parent.parent / "plugin.json"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def detect_platform() -> str:
|
|
57
|
+
"""Map this host to an asset key in plugin.json.
|
|
58
|
+
|
|
59
|
+
Linux x86_64 is served the static musl build, which runs on glibc; upstream
|
|
60
|
+
routes Linux x86_64 to musl in its own Homebrew formula for the same reason.
|
|
61
|
+
"""
|
|
62
|
+
system = platform.system().lower()
|
|
63
|
+
machine = platform.machine().lower()
|
|
64
|
+
arch = {
|
|
65
|
+
"x86_64": "x86_64",
|
|
66
|
+
"amd64": "x86_64",
|
|
67
|
+
"arm64": "arm64" if system == "darwin" else "aarch64",
|
|
68
|
+
"aarch64": "arm64" if system == "darwin" else "aarch64",
|
|
69
|
+
}.get(machine)
|
|
70
|
+
if arch is None:
|
|
71
|
+
raise InstallError(f"unsupported architecture: {platform.machine()}")
|
|
72
|
+
if system not in ("darwin", "linux", "windows"):
|
|
73
|
+
raise InstallError(f"unsupported platform: {platform.system()}")
|
|
74
|
+
return f"{system}-{arch}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def asset_url(binary: dict, asset: dict) -> str:
|
|
78
|
+
"""Where to fetch this asset from.
|
|
79
|
+
|
|
80
|
+
RTK_PACK_RELEASE_BASE_URL points the fetch at a mirror instead of GitHub,
|
|
81
|
+
for networks that cannot reach it directly. The digest check is unchanged,
|
|
82
|
+
so a mirror serving different bytes is rejected exactly like a corrupt
|
|
83
|
+
download.
|
|
84
|
+
"""
|
|
85
|
+
base = os.environ.get("RTK_PACK_RELEASE_BASE_URL", "").rstrip("/")
|
|
86
|
+
if base:
|
|
87
|
+
return f"{base}/{asset['file']}"
|
|
88
|
+
return (
|
|
89
|
+
f"https://github.com/{binary['release_repo']}/releases/download/"
|
|
90
|
+
f"{binary['release_tag']}/{asset['file']}"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def download(url: str, dest: Path) -> str:
|
|
95
|
+
"""Fetch to dest, returning the SHA-256 of what actually landed."""
|
|
96
|
+
digest = hashlib.sha256()
|
|
97
|
+
total = 0
|
|
98
|
+
request = urllib.request.Request(url, headers={"User-Agent": "ai-toolkit-rtk-pack"})
|
|
99
|
+
# A default TLS context for https; file:// mirrors (and the tests) take the
|
|
100
|
+
# handler that ignores it.
|
|
101
|
+
kwargs = {"timeout": DOWNLOAD_TIMEOUT}
|
|
102
|
+
if url.startswith("https:"):
|
|
103
|
+
kwargs["context"] = ssl.create_default_context()
|
|
104
|
+
try:
|
|
105
|
+
with urllib.request.urlopen(request, **kwargs) as response:
|
|
106
|
+
with dest.open("wb") as handle:
|
|
107
|
+
while True:
|
|
108
|
+
chunk = response.read(CHUNK)
|
|
109
|
+
if not chunk:
|
|
110
|
+
break
|
|
111
|
+
total += len(chunk)
|
|
112
|
+
if total > MAX_ASSET_BYTES:
|
|
113
|
+
raise InstallError(f"asset exceeds {MAX_ASSET_BYTES} bytes, refusing to continue")
|
|
114
|
+
digest.update(chunk)
|
|
115
|
+
handle.write(chunk)
|
|
116
|
+
except urllib.error.HTTPError as exc:
|
|
117
|
+
raise InstallError(f"HTTP {exc.code} fetching {url}") from exc
|
|
118
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
119
|
+
raise InstallError(f"could not fetch {url}: {exc}") from exc
|
|
120
|
+
if total == 0:
|
|
121
|
+
raise InstallError(f"empty response from {url}")
|
|
122
|
+
return digest.hexdigest()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def extract_member(archive: Path, member: str, dest: Path) -> None:
|
|
126
|
+
"""Pull exactly one flat entry out of the archive.
|
|
127
|
+
|
|
128
|
+
The archives are built to hold a single flat binary and CI asserts it, so
|
|
129
|
+
anything else means the asset is not what the manifest claims and is
|
|
130
|
+
refused rather than extracted.
|
|
131
|
+
"""
|
|
132
|
+
if archive.suffix == ".zip":
|
|
133
|
+
with zipfile.ZipFile(archive) as zf:
|
|
134
|
+
names = zf.namelist()
|
|
135
|
+
if names != [member]:
|
|
136
|
+
raise InstallError(f"expected exactly [{member}] in {archive.name}, found {names}")
|
|
137
|
+
with zf.open(member) as src, dest.open("wb") as out:
|
|
138
|
+
shutil.copyfileobj(src, out)
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
with tarfile.open(archive, "r:gz") as tf:
|
|
142
|
+
names = tf.getnames()
|
|
143
|
+
if names != [member]:
|
|
144
|
+
raise InstallError(f"expected exactly [{member}] in {archive.name}, found {names}")
|
|
145
|
+
extracted = tf.extractfile(member)
|
|
146
|
+
if extracted is None:
|
|
147
|
+
raise InstallError(f"{member} in {archive.name} is not a regular file")
|
|
148
|
+
with extracted as src, dest.open("wb") as out:
|
|
149
|
+
shutil.copyfileobj(src, out)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def already_installed(asset: dict, target: Path) -> bool:
|
|
153
|
+
"""True when this exact asset is already in place.
|
|
154
|
+
|
|
155
|
+
`ai-toolkit plugin install --editor all` calls the init step twice in a
|
|
156
|
+
single command (plugin.py invokes _copy_plugin_scripts per editor), so
|
|
157
|
+
without this the binary is downloaded twice. It also makes a re-install
|
|
158
|
+
cheap instead of re-fetching.
|
|
159
|
+
"""
|
|
160
|
+
if not target.is_file():
|
|
161
|
+
return False
|
|
162
|
+
try:
|
|
163
|
+
record = json.loads(VERSION_FILE.read_text(encoding="utf-8"))
|
|
164
|
+
except (OSError, json.JSONDecodeError):
|
|
165
|
+
return False
|
|
166
|
+
return record.get("sha256") == asset["sha256"] and record.get("asset") == asset["file"]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def install(manifest: dict) -> dict | None:
|
|
170
|
+
binary = manifest.get("binary")
|
|
171
|
+
if not binary:
|
|
172
|
+
raise InstallError("plugin.json has no 'binary' section")
|
|
173
|
+
|
|
174
|
+
key = detect_platform()
|
|
175
|
+
asset = binary.get("assets", {}).get(key)
|
|
176
|
+
if asset is None:
|
|
177
|
+
raise InstallError(f"no asset published for {key}")
|
|
178
|
+
|
|
179
|
+
url = asset_url(binary, asset)
|
|
180
|
+
install_name = binary.get("install_name", "rtk")
|
|
181
|
+
if platform.system().lower() == "windows":
|
|
182
|
+
install_name += ".exe"
|
|
183
|
+
target = INSTALL_DIR / install_name
|
|
184
|
+
|
|
185
|
+
if already_installed(asset, target):
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
189
|
+
staged = Path(tmp) / asset["file"]
|
|
190
|
+
actual = download(url, staged)
|
|
191
|
+
if actual != asset["sha256"]:
|
|
192
|
+
# Nothing is installed on a mismatch. The temp dir takes the
|
|
193
|
+
# partial download with it.
|
|
194
|
+
raise InstallError(
|
|
195
|
+
f"digest mismatch for {asset['file']}: "
|
|
196
|
+
f"expected {asset['sha256']}, got {actual}"
|
|
197
|
+
)
|
|
198
|
+
unpacked = Path(tmp) / install_name
|
|
199
|
+
extract_member(staged, asset["member"], unpacked)
|
|
200
|
+
|
|
201
|
+
INSTALL_DIR.mkdir(parents=True, exist_ok=True)
|
|
202
|
+
shutil.move(str(unpacked), str(target))
|
|
203
|
+
|
|
204
|
+
target.chmod(target.stat().st_mode | 0o111)
|
|
205
|
+
|
|
206
|
+
record = {
|
|
207
|
+
"pack_version": manifest.get("version"),
|
|
208
|
+
"upstream_version": manifest.get("upstream", {}).get("version"),
|
|
209
|
+
"release_tag": binary["release_tag"],
|
|
210
|
+
"platform": key,
|
|
211
|
+
"asset": asset["file"],
|
|
212
|
+
"sha256": asset["sha256"],
|
|
213
|
+
"binary": str(target),
|
|
214
|
+
}
|
|
215
|
+
VERSION_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
216
|
+
VERSION_FILE.write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
|
|
217
|
+
return record
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def main() -> int:
|
|
221
|
+
try:
|
|
222
|
+
manifest = json.loads(manifest_path().read_text(encoding="utf-8"))
|
|
223
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
224
|
+
print(f"rtk-pack: cannot read plugin.json: {exc}", file=sys.stderr)
|
|
225
|
+
return 1
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
record = install(manifest)
|
|
229
|
+
if record is None:
|
|
230
|
+
print("rtk already installed and digest matches, nothing to do")
|
|
231
|
+
return 0
|
|
232
|
+
except InstallError as exc:
|
|
233
|
+
# plugin.py prints this as "WARN init failed" and continues, which is
|
|
234
|
+
# the intended behaviour: the hook stays wired but finds no binary and
|
|
235
|
+
# passes every command through untouched.
|
|
236
|
+
print(
|
|
237
|
+
f"rtk-pack: no binary installed ({exc}). "
|
|
238
|
+
"The pack is inert: commands are passed through unchanged. "
|
|
239
|
+
"Re-run 'ai-toolkit plugin install rtk-pack' once the cause is resolved.",
|
|
240
|
+
file=sys.stderr,
|
|
241
|
+
)
|
|
242
|
+
return 1
|
|
243
|
+
|
|
244
|
+
print(
|
|
245
|
+
f"rtk {record['upstream_version']} installed for {record['platform']} "
|
|
246
|
+
f"({record['binary']}), digest verified"
|
|
247
|
+
)
|
|
248
|
+
return 0
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
if __name__ == "__main__":
|
|
252
|
+
sys.exit(main())
|