@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 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: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
7
7
  [![Skills](https://img.shields.io/badge/skills-108-brightgreen)](app/skills/)
8
8
  [![Agents](https://img.shields.io/badge/agents-44-blue)](app/agents/)
9
- [![Tests](https://img.shields.io/badge/tests-1445%20passing-success)](tests/)
9
+ [![Tests](https://img.shields.io/badge/tests-1484%20passing-success)](tests/)
10
10
 
11
- ## What's New in v4.17.0
11
+ ## What's New in v4.18.0
12
12
 
13
- v4.17.0 removes the native tool-output filter shipped in v4.16.0.
13
+ v4.18.0 adds `rtk-pack`, an opt-in command rewriter, and makes `ai-toolkit update` maintain installed packs.
14
14
 
15
- - **Native tool-output filter removed**: measured whole-session token saving was 0.0000% on real traffic, because agent-issued commands are overwhelmingly compound and the filter only accepted simple registered shapes. The `PostToolUse` hook, the CLI, and the `toolOutputFilter` config key are gone.
16
- - **Automatic cleanup on update**: `ai-toolkit update` removes the orphaned hook script, the global and project policy files, and any stored recovery artifacts left by v4.16.x. Foreign files in those directories are preserved.
17
- - **Stricter config validation**: unknown top-level config keys are rejected, plugin manifests must declare `requires`, and lock files go stale on toolkit version bumps.
18
- - **GitHub Copilot compatibility reference**: new `kb/reference/copilot-compatibility.md` documenting the Copilot integration surface.
19
- - **Copilot remnant recovery**: asset-only `.github/skills/ai-toolkit-*` leftovers from older cleanups are rebuilt in place instead of aborting `install --local` / `update`.
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/` | 11 packs | Opt-in domain bundles (security, research, frontend, enterprise, 6 language packs) |
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 (1445 tests)
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.17.0",
6
+ "version": "4.18.0",
7
7
  "author": {
8
8
  "name": "SoftSpark",
9
9
  "url": "https://github.com/softspark"
@@ -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
- - `security-pack`
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())